Skip to main content

codex_ops/
cli.rs

1use crate::cycles::CycleCommandOptions;
2use crate::stats::StatCommandOptions;
3use clap::error::ErrorKind;
4use clap::{Args, CommandFactory, Parser, Subcommand};
5use std::env;
6use std::path::PathBuf;
7
8const ROOT_USAGE: &str = "codex-ops <command> [options]";
9const AUTH_USAGE: &str = "codex-ops auth <command> [options]";
10const AUTH_STATUS_USAGE: &str = "codex-ops auth status [options]";
11const AUTH_SAVE_USAGE: &str = "codex-ops auth save [options]";
12const AUTH_LIST_USAGE: &str = "codex-ops auth list [options]";
13const AUTH_SELECT_USAGE: &str = "codex-ops auth select [options]";
14const AUTH_REMOVE_USAGE: &str = "codex-ops auth remove [options]";
15const DOCTOR_USAGE: &str = "codex-ops doctor [options]";
16
17#[derive(Debug, Clone, Eq, PartialEq)]
18pub enum ParsedCli {
19    Command(Box<CliCommand>),
20    Help(String),
21    Version,
22}
23
24#[derive(Debug, Clone, Eq, PartialEq)]
25pub enum CliCommand {
26    Auth(AuthCliCommand),
27    Doctor(DoctorCliCommand),
28    Stat(StatCliCommand),
29    Cycle(CycleCliCommand),
30}
31
32#[derive(Debug, Clone, Eq, PartialEq)]
33pub enum AuthCliCommand {
34    Status(AuthStatusCliOptions),
35    Save(AuthProfileCliOptions),
36    List(AuthProfileCliOptions),
37    Select(AuthSelectCliOptions),
38    Remove(AuthRemoveCliOptions),
39}
40
41#[derive(Debug, Clone, Eq, PartialEq)]
42pub enum DoctorCliCommand {
43    Run(DoctorCliOptions),
44}
45
46#[derive(Debug, Clone, Eq, PartialEq)]
47pub struct StatCliCommand {
48    pub view: Option<String>,
49    pub session: Option<String>,
50    pub options: StatCommandOptions,
51}
52
53#[derive(Debug, Clone, Eq, PartialEq)]
54pub enum CycleCliCommand {
55    Add {
56        time_parts: Vec<String>,
57        options: CycleCommandOptions,
58    },
59    List {
60        options: CycleCommandOptions,
61    },
62    Remove {
63        anchor_id: String,
64        options: CycleCommandOptions,
65    },
66    Current {
67        options: CycleCommandOptions,
68    },
69    History {
70        cycle_id: Option<String>,
71        options: CycleCommandOptions,
72    },
73}
74
75#[derive(Debug, Clone, Default, Eq, PartialEq)]
76pub struct AuthCliPaths {
77    pub auth_file: Option<PathBuf>,
78    pub codex_home: Option<PathBuf>,
79    pub store_dir: Option<PathBuf>,
80    pub account_history_file: Option<PathBuf>,
81}
82
83#[derive(Debug, Clone, Eq, PartialEq)]
84pub struct AuthStatusCliOptions {
85    pub paths: AuthCliPaths,
86    pub json: bool,
87    pub include_token_claims: bool,
88}
89
90#[derive(Debug, Clone, Eq, PartialEq)]
91pub struct AuthProfileCliOptions {
92    pub paths: AuthCliPaths,
93}
94
95#[derive(Debug, Clone, Eq, PartialEq)]
96pub struct AuthSelectCliOptions {
97    pub paths: AuthCliPaths,
98    pub account_id: Option<String>,
99}
100
101#[derive(Debug, Clone, Eq, PartialEq)]
102pub struct AuthRemoveCliOptions {
103    pub paths: AuthCliPaths,
104    pub account_id: Option<String>,
105    pub yes: bool,
106}
107
108#[derive(Debug, Clone, Default, Eq, PartialEq)]
109pub struct DoctorCliPaths {
110    pub auth_file: Option<PathBuf>,
111    pub codex_home: Option<PathBuf>,
112    pub sessions_dir: Option<PathBuf>,
113    pub cycle_file: Option<PathBuf>,
114}
115
116#[derive(Debug, Clone, Eq, PartialEq)]
117pub struct DoctorCliOptions {
118    pub paths: DoctorCliPaths,
119    pub json: bool,
120}
121
122#[derive(Debug, Clone, Eq, PartialEq)]
123pub struct CliParseError {
124    pub code: i32,
125    pub message: String,
126}
127
128impl CliParseError {
129    fn new(code: i32, message: impl Into<String>) -> Self {
130        Self {
131            code,
132            message: message.into(),
133        }
134    }
135}
136
137#[derive(Debug, Parser)]
138#[command(
139    name = "codex-ops",
140    disable_help_subcommand = true,
141    disable_version_flag = true,
142    override_usage = ROOT_USAGE,
143    color = clap::ColorChoice::Never
144)]
145struct CliArgs {
146    #[arg(short = 'V', long, help = "Print version")]
147    version: bool,
148
149    #[command(subcommand)]
150    command: Option<RootCommand>,
151}
152
153#[derive(Debug, Subcommand)]
154enum RootCommand {
155    #[command(
156        about = "Show and manage Codex authentication information",
157        override_usage = AUTH_USAGE
158    )]
159    Auth(AuthArgs),
160    #[command(
161        about = "Check local Codex Ops configuration and data",
162        override_usage = DOCTOR_USAGE
163    )]
164    Doctor(DoctorArgs),
165    #[command(
166        about = "Show Codex session token usage statistics",
167        override_usage = "codex-ops stat [view] [session] [options]"
168    )]
169    Stat(StatArgs),
170    #[command(
171        about = "Manage Codex weekly limit cycle anchors and usage reports",
172        override_usage = "codex-ops cycle <command> [options]"
173    )]
174    Cycle(CycleArgs),
175}
176
177#[derive(Debug, Args)]
178struct AuthArgs {
179    #[command(subcommand)]
180    command: Option<AuthCommand>,
181}
182
183#[derive(Debug, Subcommand)]
184enum AuthCommand {
185    #[command(
186        about = "Decode auth.json and show key claims",
187        override_usage = AUTH_STATUS_USAGE
188    )]
189    Status(AuthStatusArgs),
190    #[command(
191        about = "Persist the current auth.json by account id",
192        override_usage = AUTH_SAVE_USAGE
193    )]
194    Save(AuthProfileArgs),
195    #[command(
196        about = "List current and persisted auth profiles",
197        override_usage = AUTH_LIST_USAGE
198    )]
199    List(AuthProfileArgs),
200    #[command(
201        about = "Activate a persisted auth profile",
202        override_usage = AUTH_SELECT_USAGE
203    )]
204    Select(AuthSelectArgs),
205    #[command(
206        about = "Remove persisted auth profiles",
207        override_usage = AUTH_REMOVE_USAGE
208    )]
209    Remove(AuthRemoveArgs),
210}
211
212#[derive(Debug, Args)]
213struct AuthStatusArgs {
214    #[arg(long, value_name = "path", help = "Path to auth.json")]
215    auth_file: Option<PathBuf>,
216    #[arg(long, value_name = "path", help = "Codex home directory")]
217    codex_home: Option<PathBuf>,
218    #[arg(short = 'j', long, help = "Print JSON")]
219    json: bool,
220    #[arg(long, help = "Include decoded JWT header and claims in JSON output")]
221    include_token_claims: bool,
222}
223
224#[derive(Debug, Args)]
225struct AuthProfileArgs {
226    #[arg(long, value_name = "path", help = "Path to auth.json")]
227    auth_file: Option<PathBuf>,
228    #[arg(long, value_name = "path", help = "Codex home directory")]
229    codex_home: Option<PathBuf>,
230    #[arg(long, value_name = "path", help = "Auth profile store directory")]
231    store_dir: Option<PathBuf>,
232}
233
234#[derive(Debug, Args)]
235struct AuthSelectArgs {
236    #[arg(long, value_name = "path", help = "Path to auth.json")]
237    auth_file: Option<PathBuf>,
238    #[arg(long, value_name = "path", help = "Codex home directory")]
239    codex_home: Option<PathBuf>,
240    #[arg(long, value_name = "path", help = "Auth profile store directory")]
241    store_dir: Option<PathBuf>,
242    #[arg(long, value_name = "path", help = "Auth account history file")]
243    account_history_file: Option<PathBuf>,
244    #[arg(
245        short = 'A',
246        long,
247        value_name = "id",
248        help = "Activate a specific persisted account id"
249    )]
250    account_id: Option<String>,
251}
252
253#[derive(Debug, Args)]
254struct AuthRemoveArgs {
255    #[arg(long, value_name = "path", help = "Path to auth.json")]
256    auth_file: Option<PathBuf>,
257    #[arg(long, value_name = "path", help = "Codex home directory")]
258    codex_home: Option<PathBuf>,
259    #[arg(long, value_name = "path", help = "Auth profile store directory")]
260    store_dir: Option<PathBuf>,
261    #[arg(
262        short = 'A',
263        long,
264        value_name = "id",
265        help = "Remove a specific persisted account id"
266    )]
267    account_id: Option<String>,
268    #[arg(
269        short = 'y',
270        long,
271        help = "Skip confirmation when --account-id is supplied"
272    )]
273    yes: bool,
274}
275
276#[derive(Debug, Args)]
277struct DoctorArgs {
278    #[arg(long, value_name = "path", help = "Path to auth.json")]
279    auth_file: Option<PathBuf>,
280    #[arg(long, value_name = "path", help = "Codex home directory")]
281    codex_home: Option<PathBuf>,
282    #[arg(long, value_name = "path", help = "Codex sessions directory")]
283    sessions_dir: Option<PathBuf>,
284    #[arg(long, value_name = "path", help = "Weekly cycle anchor store file")]
285    cycle_file: Option<PathBuf>,
286    #[arg(short = 'j', long, help = "Print JSON")]
287    json: bool,
288}
289
290#[derive(Debug, Args)]
291struct StatArgs {
292    #[arg(value_name = "view")]
293    view: Option<String>,
294    #[arg(value_name = "session")]
295    session: Option<String>,
296    #[command(flatten)]
297    options: StatOptionArgs,
298}
299
300#[derive(Debug, Args)]
301struct StatOptionArgs {
302    #[arg(
303        short = 'g',
304        long,
305        value_name = "group",
306        help = "hour, day, week, month, model, cwd, account"
307    )]
308    group_by: Option<String>,
309    #[arg(
310        short = 'S',
311        long,
312        value_name = "sort",
313        help = "time, tokens, credits, calls, sessions"
314    )]
315    sort: Option<String>,
316    #[arg(short = 'n', long, value_name = "n", help = "Maximum rows to show")]
317    limit: Option<String>,
318    #[arg(
319        short = 'T',
320        long,
321        value_name = "n",
322        help = "Number of sessions to show"
323    )]
324    top: Option<String>,
325    #[arg(short = 'd', long, help = "Show full event-level rows")]
326    detail: bool,
327    #[arg(short = 'F', long, help = "Scan all session files")]
328    full_scan: bool,
329    #[arg(short = 'a', long, help = "Include all session usage")]
330    all: bool,
331    #[arg(short = 'r', long, help = "Include reasoning effort in model grouping")]
332    reasoning_effort: bool,
333    #[arg(
334        short = 'A',
335        long,
336        value_name = "id",
337        help = "Only include one account id"
338    )]
339    account_id: Option<String>,
340    #[arg(long, value_name = "path", help = "Path to auth.json")]
341    auth_file: Option<PathBuf>,
342    #[arg(long, value_name = "path", help = "Auth account history file")]
343    account_history_file: Option<PathBuf>,
344    #[arg(long, value_name = "path", help = "Codex home directory")]
345    codex_home: Option<PathBuf>,
346    #[arg(long, value_name = "path", help = "Codex sessions directory")]
347    sessions_dir: Option<PathBuf>,
348    #[arg(short = 's', long, value_name = "time", help = "Start time")]
349    start: Option<String>,
350    #[arg(short = 'e', long, value_name = "time", help = "End time")]
351    end: Option<String>,
352    #[arg(short = 't', long, help = "Use today as the range")]
353    today: bool,
354    #[arg(long, help = "Use yesterday as the range")]
355    yesterday: bool,
356    #[arg(short = 'm', long, help = "Use the current calendar month")]
357    month: bool,
358    #[arg(
359        short = 'L',
360        long,
361        value_name = "duration",
362        help = "Recent duration such as 12h, 7d, 2w, 1mo"
363    )]
364    last: Option<String>,
365    #[arg(
366        short = 'f',
367        long,
368        value_name = "format",
369        help = "table, json, csv, markdown"
370    )]
371    format: Option<String>,
372    #[arg(short = 'j', long, help = "Print JSON")]
373    json: bool,
374    #[arg(short = 'v', long, help = "Show diagnostics")]
375    verbose: bool,
376}
377
378#[derive(Debug, Args)]
379struct CycleArgs {
380    #[command(subcommand)]
381    command: Option<CycleSubcommand>,
382}
383
384#[derive(Debug, Subcommand)]
385enum CycleSubcommand {
386    #[command(
387        about = "Add a weekly cycle anchor",
388        override_usage = "codex-ops cycle add <time...> [options]"
389    )]
390    Add(CycleAddArgs),
391    #[command(
392        about = "List weekly cycle anchors",
393        override_usage = "codex-ops cycle list [options]"
394    )]
395    List(CycleListArgs),
396    #[command(
397        about = "Remove a weekly cycle anchor",
398        override_usage = "codex-ops cycle remove <anchor-id> [options]"
399    )]
400    Remove(CycleRemoveArgs),
401    #[command(
402        about = "Show the current weekly cycle",
403        override_usage = "codex-ops cycle current [options]"
404    )]
405    Current(CycleCurrentArgs),
406    #[command(
407        about = "Show weekly cycle history",
408        override_usage = "codex-ops cycle history [cycle-id] [options]"
409    )]
410    History(CycleHistoryArgs),
411}
412
413#[derive(Debug, Args)]
414struct CycleAddArgs {
415    #[arg(value_name = "time")]
416    time_parts: Vec<String>,
417    #[arg(short = 'n', long, value_name = "text", help = "Anchor note")]
418    note: Option<String>,
419    #[command(flatten)]
420    account: CycleAccountArgs,
421}
422
423#[derive(Debug, Args)]
424struct CycleListArgs {
425    #[command(flatten)]
426    account: CycleAccountArgs,
427    #[command(flatten)]
428    format: CycleFormatArgs,
429}
430
431#[derive(Debug, Args)]
432struct CycleRemoveArgs {
433    #[arg(value_name = "anchor-id")]
434    anchor_id: String,
435    #[command(flatten)]
436    account: CycleAccountArgs,
437}
438
439#[derive(Debug, Args)]
440struct CycleCurrentArgs {
441    #[command(flatten)]
442    account: CycleAccountArgs,
443    #[arg(long, value_name = "path", help = "Codex sessions directory")]
444    sessions_dir: Option<PathBuf>,
445    #[command(flatten)]
446    format: CycleFormatArgs,
447}
448
449#[derive(Debug, Args)]
450struct CycleHistoryArgs {
451    #[arg(value_name = "cycle-id")]
452    cycle_id: Option<String>,
453    #[arg(short = 'i', long, help = "Interactively select a cycle detail")]
454    select: bool,
455    #[arg(long, help = "Include estimated cycles before earliest anchor")]
456    estimate_before_anchor: bool,
457    #[command(flatten)]
458    account: CycleAccountArgs,
459    #[arg(long, value_name = "path", help = "Codex sessions directory")]
460    sessions_dir: Option<PathBuf>,
461    #[arg(short = 's', long, value_name = "time", help = "Start time")]
462    start: Option<String>,
463    #[arg(short = 'e', long, value_name = "time", help = "End time")]
464    end: Option<String>,
465    #[arg(short = 't', long, help = "Use today as the range")]
466    today: bool,
467    #[arg(long, help = "Use yesterday as the range")]
468    yesterday: bool,
469    #[arg(short = 'm', long, help = "Use the current calendar month")]
470    month: bool,
471    #[arg(
472        short = 'L',
473        long,
474        value_name = "duration",
475        help = "Recent duration such as 12h, 7d, 2w, 1mo"
476    )]
477    last: Option<String>,
478    #[arg(short = 'a', long, help = "Include all session usage")]
479    all: bool,
480    #[command(flatten)]
481    format: CycleFormatArgs,
482    #[arg(short = 'v', long, help = "Show diagnostics")]
483    verbose: bool,
484}
485
486#[derive(Debug, Args)]
487struct CycleAccountArgs {
488    #[arg(short = 'A', long, value_name = "id", help = "Weekly cycle account id")]
489    account_id: Option<String>,
490    #[arg(long, value_name = "path", help = "Path to auth.json")]
491    auth_file: Option<PathBuf>,
492    #[arg(long, value_name = "path", help = "Codex home directory")]
493    codex_home: Option<PathBuf>,
494    #[arg(long, value_name = "path", help = "Weekly cycle anchor store file")]
495    cycle_file: Option<PathBuf>,
496    #[arg(long, value_name = "path", help = "Auth account history file")]
497    account_history_file: Option<PathBuf>,
498}
499
500#[derive(Debug, Args)]
501struct CycleFormatArgs {
502    #[arg(
503        short = 'f',
504        long,
505        value_name = "format",
506        help = "table, json, csv, markdown"
507    )]
508    format: Option<String>,
509    #[arg(short = 'j', long, help = "Print JSON")]
510    json: bool,
511}
512
513pub fn parse_cli(args: &[String]) -> Result<ParsedCli, CliParseError> {
514    let argv = std::iter::once("codex-ops".to_string()).chain(args.iter().cloned());
515    match CliArgs::try_parse_from(argv) {
516        Ok(parsed) => parsed.into_parsed_cli(),
517        Err(error) => match error.kind() {
518            ErrorKind::DisplayHelp => Ok(ParsedCli::Help(error.to_string())),
519            ErrorKind::DisplayVersion => Ok(ParsedCli::Version),
520            _ => Err(CliParseError::new(error.exit_code(), error.to_string())),
521        },
522    }
523}
524
525impl CliArgs {
526    fn into_parsed_cli(self) -> Result<ParsedCli, CliParseError> {
527        if self.version {
528            return Ok(ParsedCli::Version);
529        }
530
531        match self.command {
532            None => Ok(ParsedCli::Help(root_help())),
533            Some(RootCommand::Auth(args)) => auth_command(args),
534            Some(RootCommand::Doctor(args)) => Ok(parsed_command(CliCommand::Doctor(
535                DoctorCliCommand::Run(doctor_options(args)?),
536            ))),
537            Some(RootCommand::Stat(args)) => {
538                Ok(parsed_command(CliCommand::Stat(stat_command(args)?)))
539            }
540            Some(RootCommand::Cycle(args)) => cycle_command(args),
541        }
542    }
543}
544
545fn auth_command(args: AuthArgs) -> Result<ParsedCli, CliParseError> {
546    let command = match args.command {
547        None => return Ok(ParsedCli::Help(auth_help())),
548        Some(AuthCommand::Status(args)) => AuthCliCommand::Status(AuthStatusCliOptions {
549            paths: AuthCliPaths {
550                auth_file: resolve_cli_path(args.auth_file)?,
551                codex_home: args.codex_home,
552                ..AuthCliPaths::default()
553            },
554            json: args.json,
555            include_token_claims: args.include_token_claims,
556        }),
557        Some(AuthCommand::Save(args)) => AuthCliCommand::Save(AuthProfileCliOptions {
558            paths: auth_profile_paths(args)?,
559        }),
560        Some(AuthCommand::List(args)) => AuthCliCommand::List(AuthProfileCliOptions {
561            paths: auth_profile_paths(args)?,
562        }),
563        Some(AuthCommand::Select(args)) => AuthCliCommand::Select(AuthSelectCliOptions {
564            paths: AuthCliPaths {
565                auth_file: resolve_cli_path(args.auth_file)?,
566                codex_home: args.codex_home,
567                store_dir: resolve_cli_path(args.store_dir)?,
568                account_history_file: resolve_cli_path(args.account_history_file)?,
569            },
570            account_id: args.account_id,
571        }),
572        Some(AuthCommand::Remove(args)) => AuthCliCommand::Remove(AuthRemoveCliOptions {
573            paths: AuthCliPaths {
574                auth_file: resolve_cli_path(args.auth_file)?,
575                codex_home: args.codex_home,
576                store_dir: resolve_cli_path(args.store_dir)?,
577                account_history_file: None,
578            },
579            account_id: args.account_id,
580            yes: args.yes,
581        }),
582    };
583
584    Ok(parsed_command(CliCommand::Auth(command)))
585}
586
587fn auth_profile_paths(args: AuthProfileArgs) -> Result<AuthCliPaths, CliParseError> {
588    Ok(AuthCliPaths {
589        auth_file: resolve_cli_path(args.auth_file)?,
590        codex_home: args.codex_home,
591        store_dir: resolve_cli_path(args.store_dir)?,
592        account_history_file: None,
593    })
594}
595
596fn doctor_options(args: DoctorArgs) -> Result<DoctorCliOptions, CliParseError> {
597    Ok(DoctorCliOptions {
598        paths: DoctorCliPaths {
599            auth_file: resolve_cli_path(args.auth_file)?,
600            codex_home: args.codex_home,
601            sessions_dir: args.sessions_dir,
602            cycle_file: resolve_cli_path(args.cycle_file)?,
603        },
604        json: args.json,
605    })
606}
607
608fn stat_command(args: StatArgs) -> Result<StatCliCommand, CliParseError> {
609    Ok(StatCliCommand {
610        view: args.view,
611        session: args.session,
612        options: stat_options(args.options)?,
613    })
614}
615
616fn stat_options(args: StatOptionArgs) -> Result<StatCommandOptions, CliParseError> {
617    Ok(StatCommandOptions {
618        start: args.start,
619        end: args.end,
620        group_by: args.group_by,
621        format: args.format,
622        codex_home: args.codex_home,
623        sessions_dir: args.sessions_dir,
624        auth_file: resolve_cli_path(args.auth_file)?,
625        account_history_file: resolve_cli_path(args.account_history_file)?,
626        today: args.today,
627        yesterday: args.yesterday,
628        month: args.month,
629        all: args.all,
630        reasoning_effort: args.reasoning_effort,
631        account_id: args.account_id,
632        last: args.last,
633        sort: args.sort,
634        limit: args.limit,
635        top: args.top,
636        detail: args.detail,
637        full_scan: args.full_scan,
638        verbose: args.verbose,
639        json: args.json,
640    })
641}
642
643fn cycle_command(args: CycleArgs) -> Result<ParsedCli, CliParseError> {
644    let command = match args.command {
645        None => return Ok(ParsedCli::Help(cycle_help())),
646        Some(CycleSubcommand::Add(args)) => CycleCliCommand::Add {
647            time_parts: args.time_parts,
648            options: cycle_options(args.account, None, None, None, args.note, None, None)?,
649        },
650        Some(CycleSubcommand::List(args)) => CycleCliCommand::List {
651            options: cycle_options(
652                args.account,
653                None,
654                None,
655                Some(args.format),
656                None,
657                None,
658                None,
659            )?,
660        },
661        Some(CycleSubcommand::Remove(args)) => CycleCliCommand::Remove {
662            anchor_id: args.anchor_id,
663            options: cycle_options(args.account, None, None, None, None, None, None)?,
664        },
665        Some(CycleSubcommand::Current(args)) => CycleCliCommand::Current {
666            options: cycle_options(
667                args.account,
668                args.sessions_dir,
669                None,
670                Some(args.format),
671                None,
672                None,
673                None,
674            )?,
675        },
676        Some(CycleSubcommand::History(args)) => CycleCliCommand::History {
677            cycle_id: args.cycle_id,
678            options: cycle_options(
679                args.account,
680                args.sessions_dir,
681                Some(CycleHistoryRangeArgs {
682                    start: args.start,
683                    end: args.end,
684                    today: args.today,
685                    yesterday: args.yesterday,
686                    month: args.month,
687                    last: args.last,
688                    all: args.all,
689                    verbose: args.verbose,
690                }),
691                Some(args.format),
692                None,
693                Some(args.select),
694                Some(args.estimate_before_anchor),
695            )?,
696        },
697    };
698
699    Ok(parsed_command(CliCommand::Cycle(command)))
700}
701
702struct CycleHistoryRangeArgs {
703    start: Option<String>,
704    end: Option<String>,
705    today: bool,
706    yesterday: bool,
707    month: bool,
708    last: Option<String>,
709    all: bool,
710    verbose: bool,
711}
712
713fn cycle_options(
714    account: CycleAccountArgs,
715    sessions_dir: Option<PathBuf>,
716    history: Option<CycleHistoryRangeArgs>,
717    format: Option<CycleFormatArgs>,
718    note: Option<String>,
719    select: Option<bool>,
720    estimate_before_anchor: Option<bool>,
721) -> Result<CycleCommandOptions, CliParseError> {
722    let auth_file = resolve_cli_path(account.auth_file)?;
723    let cycle_file = resolve_cli_path(account.cycle_file)?;
724    let account_history_file = resolve_cli_path(account.account_history_file)?;
725    let mut stat = StatCommandOptions {
726        auth_file: auth_file.clone(),
727        codex_home: account.codex_home.clone(),
728        sessions_dir: sessions_dir.clone(),
729        account_history_file: account_history_file.clone(),
730        account_id: account.account_id.clone(),
731        ..StatCommandOptions::default()
732    };
733
734    if let Some(history) = history {
735        stat.start = history.start;
736        stat.end = history.end;
737        stat.today = history.today;
738        stat.yesterday = history.yesterday;
739        stat.month = history.month;
740        stat.last = history.last;
741        stat.all = history.all;
742        stat.verbose = history.verbose;
743    }
744
745    let (format, json) = format
746        .map(|format| (format.format, format.json))
747        .unwrap_or((None, false));
748    stat.json = json;
749
750    Ok(CycleCommandOptions {
751        auth_file,
752        codex_home: account.codex_home,
753        cycle_file,
754        account_history_file,
755        sessions_dir,
756        account_id: account.account_id,
757        note,
758        format,
759        json,
760        select: select.unwrap_or(false),
761        estimate_before_anchor: estimate_before_anchor.unwrap_or(false),
762        stat,
763    })
764}
765
766fn resolve_cli_path(path: Option<PathBuf>) -> Result<Option<PathBuf>, CliParseError> {
767    match path {
768        None => Ok(None),
769        Some(path) if path.is_absolute() => Ok(Some(path)),
770        Some(path) => env::current_dir()
771            .map(|cwd| Some(cwd.join(path)))
772            .map_err(|error| CliParseError::new(1, error.to_string())),
773    }
774}
775
776fn parsed_command(command: CliCommand) -> ParsedCli {
777    ParsedCli::Command(Box::new(command))
778}
779
780fn root_help() -> String {
781    let mut command = CliArgs::command();
782    command.render_help().to_string()
783}
784
785fn auth_help() -> String {
786    let mut command = CliArgs::command();
787    let auth = command
788        .find_subcommand_mut("auth")
789        .expect("auth subcommand is defined");
790    auth.render_help().to_string()
791}
792
793fn cycle_help() -> String {
794    let mut command = CliArgs::command();
795    let cycle = command
796        .find_subcommand_mut("cycle")
797        .expect("cycle subcommand is defined");
798    cycle.render_help().to_string()
799}
800
801#[cfg(test)]
802mod tests {
803    use super::*;
804
805    #[test]
806    fn resolves_path_options_from_space_separated_flags() {
807        let args = vec![
808            "auth".to_string(),
809            "status".to_string(),
810            "--auth-file".to_string(),
811            "fixtures/auth.json".to_string(),
812        ];
813
814        let parsed = parse_cli(&args).expect("parse cli");
815        let ParsedCli::Command(command) = parsed else {
816            panic!("expected command");
817        };
818        let CliCommand::Auth(AuthCliCommand::Status(options)) = *command else {
819            panic!("expected auth status");
820        };
821
822        assert_eq!(
823            options.paths.auth_file,
824            Some(env::current_dir().expect("cwd").join("fixtures/auth.json"))
825        );
826    }
827
828    #[test]
829    fn resolves_path_options_from_equals_flags() {
830        let args = vec![
831            "cycle".to_string(),
832            "history".to_string(),
833            "--cycle-file=fixtures/cycles.json".to_string(),
834            "--account-history-file=fixtures/history.json".to_string(),
835        ];
836
837        let parsed = parse_cli(&args).expect("parse cli");
838        let ParsedCli::Command(command) = parsed else {
839            panic!("expected command");
840        };
841        let CliCommand::Cycle(CycleCliCommand::History { options, .. }) = *command else {
842            panic!("expected cycle history");
843        };
844        let cwd = env::current_dir().expect("cwd");
845
846        assert_eq!(options.cycle_file, Some(cwd.join("fixtures/cycles.json")));
847        assert_eq!(
848            options.account_history_file,
849            Some(cwd.join("fixtures/history.json"))
850        );
851    }
852}