1use std::{ffi::OsString, path::PathBuf};
2
3use crate::arg_parser::ArgParser;
4use crate::help::{print_help_and_exit, print_version_and_exit};
5use csusage_cli::{
6 AgentCommandArgs, AgentReportKind, BlocksArgs, CliConfig, CodexSpeed, Command, CostMode,
7 CostSource, DATE_BOUND_FORMATS, DailyArgs, OPENCODE_AGENT_REPORTS, STANDARD_AGENT_REPORTS,
8 SessionArgs, SharedArgs, SortOrder, StatuslineArgs, VisualBurnRate, WeekDay, WeeklyArgs,
9 normalize_date_bound,
10};
11
12use crate::Cli;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15enum ControlArg {
16 Help,
17 Version,
18}
19
20#[derive(Default)]
21struct RootAllOptions {
22 sections: Option<Vec<AgentReportKind>>,
23 by_agent: bool,
24 first_flag: Option<&'static str>,
25}
26
27impl RootAllOptions {
28 fn mark_used(&mut self, flag: &'static str) {
29 self.first_flag.get_or_insert(flag);
30 }
31
32 fn is_used(&self) -> bool {
33 self.first_flag.is_some()
34 }
35
36 fn first_flag(&self) -> &'static str {
37 self.first_flag.unwrap_or("--sections")
38 }
39
40 fn into_agent_args(self, shared: SharedArgs, kind: AgentReportKind) -> AgentCommandArgs {
41 AgentCommandArgs {
42 shared,
43 kind,
44 sections: self.sections,
45 by_agent: self.by_agent,
46 pi_path: None,
47 open_claw_path: None,
48 codex_speed: CodexSpeed::Auto,
49 }
50 }
51}
52
53impl Cli {
54 #[cfg(test)]
57 pub(crate) fn parse_from<I>(args: I) -> Result<Self, String>
58 where
59 I: IntoIterator<Item = OsString>,
60 {
61 Self::parse_from_with_config(args, &csusage_cli::NoConfig, 5.0, env!("CARGO_PKG_VERSION"))
62 }
63
64 pub fn parse_from_with_config<I>(
65 args: I,
66 config: &dyn CliConfig,
67 default_session_duration_hours: f64,
68 version: &'static str,
69 ) -> Result<Self, String>
70 where
71 I: IntoIterator<Item = OsString>,
72 {
73 let mut parser = ArgParser::new(args.into_iter().skip(1).collect())?;
74 normalize_legacy_agent_command_args(&mut parser.args);
75 match control_arg(&parser.args) {
76 Some(ControlArg::Version) => print_version_and_exit(version),
77 Some(ControlArg::Help) => print_help_and_exit(&parser.args),
78 None => {}
79 }
80 if let Some(message) = report_flag_alias_error(&parser.args) {
81 return Err(message);
82 }
83 if let Some(message) = agent_filter_option_error(&parser.args) {
84 return Err(message);
85 }
86 if let Some(message) = unsupported_agent_report_error(&parser.args) {
87 return Err(message);
88 }
89 if let Some(message) = config.config_error() {
90 return Err(message.to_string());
91 }
92 let mut shared = SharedArgs::with_defaults();
93 config.apply_shared(&mut shared);
94 let mut root_all_options = RootAllOptions::default();
95 while let Some(arg) = parser.peek() {
96 if is_command(arg) {
97 break;
98 }
99 if !arg.starts_with('-') {
100 return Err(format!("Unknown command '{arg}'"));
101 }
102 if parse_root_all_arg(&mut parser, &mut root_all_options)? {
103 continue;
104 }
105 parse_shared_arg(&mut parser, &mut shared)?;
106 }
107
108 let command = match parser.next() {
109 None if root_all_options.is_used() => Some(Command::All(
110 root_all_options.into_agent_args(shared.clone(), AgentReportKind::Daily),
111 )),
112 None => None,
113 Some(command) => Some(parse_command(
114 &command,
115 &mut parser,
116 shared.clone(),
117 config,
118 default_session_duration_hours,
119 root_all_options,
120 )?),
121 };
122 if let Some(extra) = parser.next() {
123 return Err(format!("Unexpected argument '{extra}'"));
124 }
125 if let Some(message) = last_option_error(command.as_ref(), &shared) {
126 return Err(message);
127 }
128 Ok(Self { command, shared })
129 }
130}
131
132fn control_arg(args: &[String]) -> Option<ControlArg> {
133 if args
134 .iter()
135 .any(|arg| matches!(arg.as_str(), "-v" | "-V" | "--version"))
136 {
137 return Some(ControlArg::Version);
138 }
139 if args
140 .iter()
141 .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
142 {
143 return Some(ControlArg::Help);
144 }
145 None
146}
147
148fn parse_command(
149 command: &str,
150 parser: &mut ArgParser,
151 shared: SharedArgs,
152 config: &dyn CliConfig,
153 default_session_duration_hours: f64,
154 root_all_options: RootAllOptions,
155) -> Result<Command, String> {
156 if root_all_options.is_used() && !accepts_root_all_options(command) {
157 return Err(format!(
158 "Unknown option '{}'",
159 root_all_options.first_flag()
160 ));
161 }
162 match command {
163 "daily" => parse_all_command(
164 parser,
165 shared,
166 AgentReportKind::Daily,
167 config,
168 root_all_options,
169 ),
170 "monthly" => parse_all_command(
171 parser,
172 shared,
173 AgentReportKind::Monthly,
174 config,
175 root_all_options,
176 ),
177 "weekly" => parse_all_command(
178 parser,
179 shared,
180 AgentReportKind::Weekly,
181 config,
182 root_all_options,
183 ),
184 "session" => parse_top_level_session_command(parser, shared, config, root_all_options),
185 "blocks" => {
186 let mut args = BlocksArgs {
187 shared,
188 active: false,
189 recent: false,
190 token_limit: None,
191 session_length: default_session_duration_hours,
192 };
193 config.apply_blocks_args(&mut args);
194 while parser.peek().is_some() {
195 if parse_shared_arg_for_command(parser, &mut args.shared)? {
196 continue;
197 }
198 match parser.next_flag()?.as_str() {
199 "-a" | "--active" => args.active = true,
200 "-r" | "--recent" => args.recent = true,
201 "-t" | "--token-limit" => {
202 args.token_limit = Some(parser.value_for("--token-limit")?)
203 }
204 "-n" | "--session-length" => {
205 args.session_length = parser
206 .value_for("--session-length")?
207 .parse()
208 .map_err(|_| "Invalid value for --session-length".to_string())?
209 }
210 flag => return Err(format!("Unknown blocks option '{flag}'")),
211 }
212 }
213 Ok(Command::Blocks(args))
214 }
215 "statusline" => {
216 let mut args = StatuslineArgs::default();
217 config.apply_statusline_args(&mut args);
218 while parser.peek().is_some() {
219 match parser.next_flag()?.as_str() {
220 "-O" | "--offline" => args.offline = true,
221 "--no-offline" => args.no_offline = true,
222 "-B" | "--visual-burn-rate" => {
223 args.visual_burn_rate =
224 parse_visual_burn_rate(&parser.value_for("--visual-burn-rate")?)?
225 }
226 "--cost-source" => {
227 args.cost_source = parse_cost_source(&parser.value_for("--cost-source")?)?
228 }
229 "--cache" => args.cache = true,
230 "--no-cache" => args.no_cache = true,
231 "--refresh-interval" => {
232 args.refresh_interval = parser
233 .value_for("--refresh-interval")?
234 .parse()
235 .map_err(|_| "Invalid value for --refresh-interval".to_string())?
236 }
237 "--context-low-threshold" => {
238 args.context_low_threshold = parser
239 .value_for("--context-low-threshold")?
240 .parse()
241 .map_err(|_| "Invalid value for --context-low-threshold".to_string())?
242 }
243 "--context-medium-threshold" => {
244 args.context_medium_threshold = parser
245 .value_for("--context-medium-threshold")?
246 .parse()
247 .map_err(|_| {
248 "Invalid value for --context-medium-threshold".to_string()
249 })?
250 }
251 "-z" | "--timezone" => args.timezone = Some(parser.value_for("--timezone")?),
252 "--config" => args.config = Some(PathBuf::from(parser.value_for("--config")?)),
253 "--debug" => args.debug = true,
254 flag => return Err(format!("Unknown statusline option '{flag}'")),
255 }
256 }
257 Ok(Command::Statusline(args))
258 }
259 "claude" => parse_claude_command(parser, shared, config, default_session_duration_hours),
260 "codex" => parse_codex_command(parser, shared, config),
261 "opencode" => parse_basic_agent_command(
262 parser,
263 shared,
264 "opencode",
265 OPENCODE_AGENT_REPORTS,
266 Command::OpenCode,
267 ),
268 "amp" => {
269 parse_basic_agent_command(parser, shared, "amp", STANDARD_AGENT_REPORTS, Command::Amp)
270 }
271 "droid" => parse_basic_agent_command(
272 parser,
273 shared,
274 "droid",
275 STANDARD_AGENT_REPORTS,
276 Command::Droid,
277 ),
278 "codebuff" => parse_basic_agent_command(
279 parser,
280 shared,
281 "codebuff",
282 STANDARD_AGENT_REPORTS,
283 Command::Codebuff,
284 ),
285 "hermes" => parse_basic_agent_command(
286 parser,
287 shared,
288 "hermes",
289 STANDARD_AGENT_REPORTS,
290 Command::Hermes,
291 ),
292 "pi" => parse_pi_command(parser, shared, config),
293 "goose" => parse_basic_agent_command(
294 parser,
295 shared,
296 "goose",
297 STANDARD_AGENT_REPORTS,
298 Command::Goose,
299 ),
300 "kilo" => parse_basic_agent_command(
301 parser,
302 shared,
303 "kilo",
304 STANDARD_AGENT_REPORTS,
305 Command::Kilo,
306 ),
307 "copilot" => parse_basic_agent_command(
308 parser,
309 shared,
310 "copilot",
311 STANDARD_AGENT_REPORTS,
312 Command::Copilot,
313 ),
314 "gemini" => parse_basic_agent_command(
315 parser,
316 shared,
317 "gemini",
318 STANDARD_AGENT_REPORTS,
319 Command::Gemini,
320 ),
321 "antigravity" => parse_basic_agent_command(
322 parser,
323 shared,
324 "antigravity",
325 STANDARD_AGENT_REPORTS,
326 Command::Antigravity,
327 ),
328 "kimi" => parse_basic_agent_command(
329 parser,
330 shared,
331 "kimi",
332 STANDARD_AGENT_REPORTS,
333 Command::Kimi,
334 ),
335 "qwen" => parse_basic_agent_command(
336 parser,
337 shared,
338 "qwen",
339 STANDARD_AGENT_REPORTS,
340 Command::Qwen,
341 ),
342 "openclaw" => parse_openclaw_command(parser, shared, config),
343 "grok" => parse_basic_agent_command(
344 parser,
345 shared,
346 "grok",
347 STANDARD_AGENT_REPORTS,
348 Command::Grok,
349 ),
350 "zcode" => parse_basic_agent_command(
351 parser,
352 shared,
353 "zcode",
354 STANDARD_AGENT_REPORTS,
355 Command::ZCode,
356 ),
357 "claude-science" => parse_basic_agent_command(
358 parser,
359 shared,
360 "claude-science",
361 STANDARD_AGENT_REPORTS,
362 Command::ClaudeScience,
363 ),
364 "openhands" => parse_basic_agent_command(
365 parser,
366 shared,
367 "openhands",
368 STANDARD_AGENT_REPORTS,
369 Command::OpenHands,
370 ),
371 _ => Err(format!("Unknown command '{command}'")),
372 }
373}
374
375fn accepts_root_all_options(command: &str) -> bool {
376 matches!(command, "daily" | "monthly" | "weekly" | "session")
377}
378
379fn parse_root_all_arg(
380 parser: &mut ArgParser,
381 options: &mut RootAllOptions,
382) -> Result<bool, String> {
383 if let Some(flag) =
384 parse_unified_report_arg(parser, &mut options.sections, &mut options.by_agent)?
385 {
386 options.mark_used(flag);
387 return Ok(true);
388 }
389 Ok(false)
390}
391
392fn parse_unified_report_arg(
393 parser: &mut ArgParser,
394 sections: &mut Option<Vec<AgentReportKind>>,
395 by_agent: &mut bool,
396) -> Result<Option<&'static str>, String> {
397 if matches!(parser.peek(), Some("--all")) {
398 parser.next();
399 return Ok(Some("--all"));
400 }
401 if matches!(parser.peek_name(), Some("--sections")) {
402 parser.next_flag()?;
403 *sections = Some(parse_report_sections(&parser.value_for("--sections")?)?);
404 return Ok(Some("--sections"));
405 }
406 if matches!(parser.peek(), Some("--by-agent")) {
407 parser.next();
408 *by_agent = true;
409 return Ok(Some("--by-agent"));
410 }
411 Ok(None)
412}
413
414fn parse_all_command(
415 parser: &mut ArgParser,
416 mut shared: SharedArgs,
417 kind: AgentReportKind,
418 _config: &dyn CliConfig,
419 initial_options: RootAllOptions,
420) -> Result<Command, String> {
421 let mut sections = initial_options.sections;
422 let mut by_agent = initial_options.by_agent;
423 while parser.peek().is_some() {
424 if parse_unified_report_arg(parser, &mut sections, &mut by_agent)?.is_some() {
425 continue;
426 }
427 parse_shared_arg(parser, &mut shared)?;
428 }
429 Ok(Command::All(AgentCommandArgs {
430 shared,
431 kind,
432 sections,
433 by_agent,
434 pi_path: None,
435 open_claw_path: None,
436 codex_speed: CodexSpeed::Auto,
437 }))
438}
439
440fn parse_top_level_session_command(
441 parser: &mut ArgParser,
442 shared: SharedArgs,
443 _config: &dyn CliConfig,
444 initial_options: RootAllOptions,
445) -> Result<Command, String> {
446 let mut args = SessionArgs { shared, id: None };
447 let mut sections = initial_options.sections;
448 let mut by_agent = initial_options.by_agent;
449 while parser.peek().is_some() {
450 if parse_unified_report_arg(parser, &mut sections, &mut by_agent)?.is_some() {
451 continue;
452 }
453 if parse_shared_arg_for_command(parser, &mut args.shared)? {
454 continue;
455 }
456 match parser.next_flag()?.as_str() {
457 "-i" | "--id" => args.id = Some(parser.value_for("--id")?),
458 flag => return Err(format!("Unknown session option '{flag}'")),
459 }
460 }
461
462 if args.id.is_some() {
463 if sections.is_some() || by_agent {
464 return Err(
465 "The --sections and --by-agent options cannot be used with session --id."
466 .to_string(),
467 );
468 }
469 return Ok(Command::Session(args));
470 }
471
472 Ok(Command::All(AgentCommandArgs {
473 shared: args.shared,
474 kind: AgentReportKind::Session,
475 sections,
476 by_agent,
477 pi_path: None,
478 open_claw_path: None,
479 codex_speed: CodexSpeed::Auto,
480 }))
481}
482
483fn parse_claude_daily_command(
484 parser: &mut ArgParser,
485 shared: SharedArgs,
486 config: &dyn CliConfig,
487) -> Result<Command, String> {
488 let mut args = DailyArgs {
489 shared,
490 instances: false,
491 project: None,
492 project_aliases: None,
493 };
494 config.apply_daily_args(&mut args);
495 while parser.peek().is_some() {
496 if parse_shared_arg_for_command(parser, &mut args.shared)? {
497 continue;
498 }
499 match parser.next_flag()?.as_str() {
500 "-i" | "--instances" => args.instances = true,
501 "-p" | "--project" => args.project = Some(parser.value_for("--project")?),
502 "--project-aliases" => {
503 args.project_aliases = Some(parser.value_for("--project-aliases")?)
504 }
505 flag => return Err(format!("Unknown daily option '{flag}'")),
506 }
507 }
508 Ok(Command::Daily(args))
509}
510
511fn parse_claude_monthly_command(
512 parser: &mut ArgParser,
513 mut shared: SharedArgs,
514 _config: &dyn CliConfig,
515) -> Result<Command, String> {
516 while parser.peek().is_some() {
517 parse_shared_arg(parser, &mut shared)?;
518 }
519 Ok(Command::Monthly(shared))
520}
521
522fn parse_claude_weekly_command(
523 parser: &mut ArgParser,
524 shared: SharedArgs,
525 config: &dyn CliConfig,
526) -> Result<Command, String> {
527 let mut args = WeeklyArgs {
528 shared,
529 start_of_week: WeekDay::Sunday,
530 };
531 config.apply_weekly_args(&mut args);
532 while parser.peek().is_some() {
533 if parse_shared_arg_for_command(parser, &mut args.shared)? {
534 continue;
535 }
536 match parser.next_flag()?.as_str() {
537 "-w" | "--start-of-week" => {
538 args.start_of_week = parse_week_day(&parser.value_for("--start-of-week")?)?
539 }
540 flag => return Err(format!("Unknown weekly option '{flag}'")),
541 }
542 }
543 Ok(Command::Weekly(args))
544}
545
546fn parse_claude_session_command(
547 parser: &mut ArgParser,
548 shared: SharedArgs,
549 _config: &dyn CliConfig,
550) -> Result<Command, String> {
551 let mut args = SessionArgs { shared, id: None };
552 while parser.peek().is_some() {
553 if parse_shared_arg_for_command(parser, &mut args.shared)? {
554 continue;
555 }
556 match parser.next_flag()?.as_str() {
557 "-i" | "--id" => args.id = Some(parser.value_for("--id")?),
558 flag => return Err(format!("Unknown session option '{flag}'")),
559 }
560 }
561 Ok(Command::Session(args))
562}
563
564fn parse_claude_command(
565 parser: &mut ArgParser,
566 shared: SharedArgs,
567 config: &dyn CliConfig,
568 default_session_duration_hours: f64,
569) -> Result<Command, String> {
570 let command = match parser.peek() {
571 Some(command @ ("daily" | "monthly" | "weekly" | "session" | "blocks" | "statusline")) => {
572 let command = command.to_string();
573 parser.next();
574 command
575 }
576 Some(command) if !command.starts_with('-') => {
577 return Err(format!("Unknown claude command '{command}'"));
578 }
579 _ => "daily".to_string(),
580 };
581 match command.as_str() {
582 "daily" => parse_claude_daily_command(parser, shared, config),
583 "monthly" => parse_claude_monthly_command(parser, shared, config),
584 "weekly" => parse_claude_weekly_command(parser, shared, config),
585 "session" => parse_claude_session_command(parser, shared, config),
586 "blocks" | "statusline" => parse_command(
587 &command,
588 parser,
589 shared,
590 config,
591 default_session_duration_hours,
592 RootAllOptions::default(),
593 ),
594 _ => unreachable!("claude command is prevalidated"),
595 }
596}
597
598fn parse_basic_agent_command(
599 parser: &mut ArgParser,
600 mut shared: SharedArgs,
601 agent: &str,
602 reports: &[(&str, AgentReportKind)],
603 command: fn(AgentCommandArgs) -> Command,
604) -> Result<Command, String> {
605 let kind = parse_agent_report_kind(parser, agent, reports)?;
606 while parser.peek().is_some() {
607 parse_shared_arg(parser, &mut shared)?;
608 }
609 Ok(command(agent_command_args(shared, kind)))
610}
611
612fn parse_codex_command(
613 parser: &mut ArgParser,
614 mut shared: SharedArgs,
615 config: &dyn CliConfig,
616) -> Result<Command, String> {
617 let kind = parse_agent_report_kind(parser, "codex", STANDARD_AGENT_REPORTS)?;
618 let mut codex_speed = CodexSpeed::Auto;
619 config.apply_agent_args(&mut codex_speed, None, None);
620 while parser.peek().is_some() {
621 if parse_shared_arg_for_command(parser, &mut shared)? {
622 continue;
623 }
624 match parser.next_flag()?.as_str() {
625 "--speed" => codex_speed = parse_codex_speed(&parser.value_for("--speed")?)?,
626 flag => return Err(format!("Unknown codex option '{flag}'")),
627 }
628 }
629 Ok(Command::Codex(AgentCommandArgs {
630 shared,
631 kind,
632 sections: None,
633 by_agent: false,
634 pi_path: None,
635 open_claw_path: None,
636 codex_speed,
637 }))
638}
639
640fn parse_pi_command(
641 parser: &mut ArgParser,
642 mut shared: SharedArgs,
643 config: &dyn CliConfig,
644) -> Result<Command, String> {
645 let kind = parse_agent_report_kind(parser, "pi", STANDARD_AGENT_REPORTS)?;
646 let mut pi_path = None;
647 let mut codex_speed = CodexSpeed::Auto;
648 config.apply_agent_args(&mut codex_speed, Some(&mut pi_path), None);
649 while parser.peek().is_some() {
650 if parse_shared_arg_for_command(parser, &mut shared)? {
651 continue;
652 }
653 match parser.next_flag()?.as_str() {
654 "--pi-path" => pi_path = Some(parser.value_for("--pi-path")?),
655 flag => return Err(format!("Unknown pi option '{flag}'")),
656 }
657 }
658 Ok(Command::Pi(AgentCommandArgs {
659 shared,
660 kind,
661 sections: None,
662 by_agent: false,
663 pi_path,
664 open_claw_path: None,
665 codex_speed,
666 }))
667}
668
669fn parse_openclaw_command(
670 parser: &mut ArgParser,
671 mut shared: SharedArgs,
672 config: &dyn CliConfig,
673) -> Result<Command, String> {
674 let kind = parse_agent_report_kind(parser, "openclaw", STANDARD_AGENT_REPORTS)?;
675 let mut open_claw_path = None;
676 let mut codex_speed = CodexSpeed::Auto;
677 config.apply_agent_args(&mut codex_speed, None, Some(&mut open_claw_path));
678 while parser.peek().is_some() {
679 if parse_shared_arg_for_command(parser, &mut shared)? {
680 continue;
681 }
682 match parser.next_flag()?.as_str() {
683 "--open-claw-path" => open_claw_path = Some(parser.value_for("--open-claw-path")?),
684 flag => return Err(format!("Unknown openclaw option '{flag}'")),
685 }
686 }
687 Ok(Command::OpenClaw(AgentCommandArgs {
688 shared,
689 kind,
690 sections: None,
691 by_agent: false,
692 pi_path: None,
693 open_claw_path,
694 codex_speed,
695 }))
696}
697
698fn parse_agent_report_kind(
699 parser: &mut ArgParser,
700 agent: &str,
701 reports: &[(&str, AgentReportKind)],
702) -> Result<AgentReportKind, String> {
703 let Some(command) = parser.peek() else {
704 return Ok(AgentReportKind::Daily);
705 };
706 if let Some((_, kind)) = reports.iter().find(|(report, _)| *report == command) {
707 parser.next();
708 return Ok(*kind);
709 }
710 if !command.starts_with('-') {
711 return Err(format!("Unknown {agent} command '{command}'"));
712 }
713 Ok(AgentReportKind::Daily)
714}
715
716fn agent_command_args(shared: SharedArgs, kind: AgentReportKind) -> AgentCommandArgs {
717 AgentCommandArgs {
718 shared,
719 kind,
720 sections: None,
721 by_agent: false,
722 pi_path: None,
723 open_claw_path: None,
724 codex_speed: CodexSpeed::Auto,
725 }
726}
727
728fn parse_shared_arg_for_command(
729 parser: &mut ArgParser,
730 shared: &mut SharedArgs,
731) -> Result<bool, String> {
732 let Some(arg) = parser.peek() else {
733 return Ok(false);
734 };
735 if is_shared_flag(arg) {
736 parse_shared_arg(parser, shared)?;
737 return Ok(true);
738 }
739 Ok(false)
740}
741
742fn parse_shared_arg(parser: &mut ArgParser, shared: &mut SharedArgs) -> Result<(), String> {
743 match parser.next_flag()?.as_str() {
744 "-s" | "--since" => {
745 shared.since = Some(parse_date_bound("--since", &parser.value_for("--since")?)?)
746 }
747 "-u" | "--until" => {
748 shared.until = Some(parse_date_bound("--until", &parser.value_for("--until")?)?)
749 }
750 "--last" => shared.last = Some(parse_last_periods(&parser.value_for("--last")?)?),
751 "-j" | "--json" => shared.json = true,
752 "-m" | "--mode" => shared.mode = parse_cost_mode(&parser.value_for("--mode")?)?,
753 "-d" | "--debug" => shared.debug = true,
754 "--debug-samples" => {
755 shared.debug_samples = parser
756 .value_for("--debug-samples")?
757 .parse()
758 .map_err(|_| "Invalid value for --debug-samples".to_string())?
759 }
760 "-o" | "--order" => shared.order = parse_sort_order(&parser.value_for("--order")?)?,
761 "-b" | "--breakdown" => shared.breakdown = true,
762 "-O" | "--offline" => shared.offline = true,
763 "--no-offline" => shared.no_offline = true,
764 "--color" => shared.color = true,
765 "--no-color" => shared.no_color = true,
766 "-z" | "--timezone" => shared.timezone = Some(parser.value_for("--timezone")?),
767 "-q" | "--jq" => shared.jq = Some(parser.value_for("--jq")?),
768 "--config" => shared.config = Some(PathBuf::from(parser.value_for("--config")?)),
769 "--compact" => shared.compact = true,
770 "--single-thread" => shared.single_thread = true,
771 "--no-cost" => shared.no_cost = true,
772 flag => return Err(format!("Unknown option '{flag}'")),
773 }
774 Ok(())
775}
776
777fn is_command(arg: &str) -> bool {
778 matches!(
779 arg,
780 "daily"
781 | "monthly"
782 | "weekly"
783 | "session"
784 | "blocks"
785 | "statusline"
786 | "claude"
787 | "codex"
788 | "opencode"
789 | "amp"
790 | "droid"
791 | "codebuff"
792 | "hermes"
793 | "pi"
794 | "goose"
795 | "openclaw"
796 | "kilo"
797 | "copilot"
798 | "gemini"
799 | "antigravity"
800 | "kimi"
801 | "qwen"
802 | "grok"
803 | "zcode"
804 | "claude-science"
805 | "openhands"
806 )
807}
808
809fn normalize_legacy_agent_command_args(args: &mut Vec<String>) {
810 let Some(command) = args.first() else {
811 return;
812 };
813 let Some((agent, report)) = command.split_once(':') else {
814 return;
815 };
816 if !legacy_agent_report_supported(agent, report) {
817 return;
818 }
819 args.splice(0..1, [agent.to_string(), report.to_string()]);
820}
821
822fn legacy_agent_report_supported(agent: &str, report: &str) -> bool {
823 agent_report_supported(agent, report)
824}
825
826fn report_flag_alias_error(args: &[String]) -> Option<String> {
827 let flag = args.iter().find(|arg| {
828 matches!(
829 arg.as_str(),
830 "--daily" | "--weekly" | "--monthly" | "--session" | "--blocks" | "--statusline"
831 )
832 })?;
833 Some(format!(
834 "Report flags like {flag} are not supported. Use \"ccusage {}\" instead.",
835 flag.trim_start_matches("--")
836 ))
837}
838
839fn agent_filter_option_error(args: &[String]) -> Option<String> {
840 let allows_short_active = blocks_command_tokens(args);
841 let flag = args.iter().find_map(|arg| {
842 if arg == "--agent" || arg.starts_with("--agent=") {
843 return Some("--agent");
844 }
845 if (arg == "-a" && !allows_short_active) || arg.starts_with("-a=") {
846 return Some("-a");
847 }
848 None
849 })?;
850 Some(format!(
851 "Agent filters like {flag} are not supported. Use \"ccusage <agent> <report>\", for example \"ccusage codex daily\"."
852 ))
853}
854
855fn blocks_command_tokens(args: &[String]) -> bool {
856 let tokens = command_tokens(args);
857 matches!(
858 tokens.as_slice(),
859 [command, ..] if command == "blocks"
860 ) || matches!(
861 tokens.as_slice(),
862 [agent, command, ..] if agent == "claude" && command == "blocks"
863 )
864}
865
866fn unsupported_agent_report_error(args: &[String]) -> Option<String> {
867 let tokens = command_tokens(args);
868 let [agent, report, ..] = tokens.as_slice() else {
869 return None;
870 };
871 if !is_agent_command(agent) || agent_report_supported(agent, report) {
872 return None;
873 }
874
875 let display = agent_display_name(agent);
876 let message = if matches!(report.as_str(), "blocks" | "statusline") {
877 format!(
878 "The \"{report}\" report is only available for Claude Code usage.\nUse \"ccusage {agent} daily\" for {display} usage reports."
879 )
880 } else {
881 format!(
882 "The \"{report}\" report is not available for {display} usage.\nUse \"ccusage {agent} daily\" for {display} usage reports."
883 )
884 };
885 Some(message)
886}
887
888pub(crate) fn command_tokens(args: &[String]) -> Vec<String> {
889 let mut tokens = Vec::new();
890 let mut index = 0;
891 while let Some(arg) = args.get(index) {
892 if arg.starts_with('-') {
893 if option_takes_value(arg) && !arg.contains('=') {
894 index += 2;
895 } else {
896 index += 1;
897 }
898 continue;
899 }
900 tokens.push(arg.clone());
901 index += 1;
902 }
903 tokens
904}
905
906fn option_takes_value(arg: &str) -> bool {
907 matches!(
908 arg,
909 "-s" | "--since"
910 | "-u"
911 | "--until"
912 | "--last"
913 | "-m"
914 | "--mode"
915 | "--debug-samples"
916 | "-o"
917 | "--order"
918 | "-z"
919 | "--timezone"
920 | "-q"
921 | "--jq"
922 | "--config"
923 | "-p"
924 | "--project"
925 | "--project-aliases"
926 | "-w"
927 | "--start-of-week"
928 | "-i"
929 | "--id"
930 | "-t"
931 | "--token-limit"
932 | "-n"
933 | "--session-length"
934 | "-B"
935 | "--visual-burn-rate"
936 | "--cost-source"
937 | "--refresh-interval"
938 | "--context-low-threshold"
939 | "--context-medium-threshold"
940 | "--speed"
941 | "--pi-path"
942 | "--open-claw-path"
943 | "--sections"
944 )
945}
946
947fn is_agent_command(command: &str) -> bool {
948 matches!(
949 command,
950 "claude"
951 | "codex"
952 | "opencode"
953 | "amp"
954 | "droid"
955 | "codebuff"
956 | "hermes"
957 | "pi"
958 | "goose"
959 | "kilo"
960 | "copilot"
961 | "gemini"
962 | "antigravity"
963 | "kimi"
964 | "qwen"
965 | "openclaw"
966 | "grok"
967 | "zcode"
968 | "claude-science"
969 | "openhands"
970 )
971}
972
973fn agent_report_supported(agent: &str, report: &str) -> bool {
974 match agent {
975 "claude" => matches!(
976 report,
977 "daily" | "weekly" | "monthly" | "session" | "blocks" | "statusline"
978 ),
979 "codex" => matches!(report, "daily" | "monthly" | "session"),
980 "opencode" => matches!(report, "daily" | "weekly" | "monthly" | "session"),
981 "amp" | "droid" | "codebuff" | "hermes" | "pi" | "goose" | "kilo" | "copilot"
982 | "gemini" | "antigravity" | "kimi" | "qwen" | "openclaw" | "grok" | "zcode"
983 | "claude-science" | "openhands" => {
984 matches!(report, "daily" | "monthly" | "session")
985 }
986 _ => false,
987 }
988}
989
990fn agent_display_name(agent: &str) -> &'static str {
991 match agent {
992 "claude" => "Claude Code",
993 "codex" => "Codex",
994 "opencode" => "OpenCode",
995 "amp" => "Amp",
996 "droid" => "Droid",
997 "codebuff" => "Codebuff",
998 "hermes" => "Hermes",
999 "pi" => "pi-agent",
1000 "goose" => "Goose",
1001 "kilo" => "Kilo",
1002 "copilot" => "GitHub Copilot CLI",
1003 "gemini" => "Gemini CLI",
1004 "antigravity" => "Antigravity",
1005 "kimi" => "Kimi",
1006 "qwen" => "Qwen",
1007 "openclaw" => "OpenClaw",
1008 "grok" => "Grok",
1009 "zcode" => "ZCode",
1010 "claude-science" => "Claude Science",
1011 "openhands" => "OpenHands",
1012 _ => unreachable!("agent is prevalidated"),
1013 }
1014}
1015
1016fn is_shared_flag(arg: &str) -> bool {
1017 matches!(
1018 arg.split_once('=').map_or(arg, |(name, _)| name),
1019 "-s" | "--since"
1020 | "-u"
1021 | "--until"
1022 | "--last"
1023 | "-j"
1024 | "--json"
1025 | "-m"
1026 | "--mode"
1027 | "-d"
1028 | "--debug"
1029 | "--debug-samples"
1030 | "-o"
1031 | "--order"
1032 | "-b"
1033 | "--breakdown"
1034 | "-O"
1035 | "--offline"
1036 | "--no-offline"
1037 | "--color"
1038 | "--no-color"
1039 | "-z"
1040 | "--timezone"
1041 | "-q"
1042 | "--jq"
1043 | "--config"
1044 | "--compact"
1045 | "--single-thread"
1046 | "--no-cost"
1047 )
1048}
1049
1050fn parse_date_bound(flag: &str, value: &str) -> Result<String, String> {
1051 normalize_date_bound(value).ok_or_else(|| {
1052 format!("Invalid value for {flag} '{value}'. Expected {DATE_BOUND_FORMATS}.")
1053 })
1054}
1055
1056fn parse_last_periods(value: &str) -> Result<u32, String> {
1057 match value.parse() {
1058 Ok(0) | Err(_) => Err(format!(
1059 "Invalid value for --last '{value}'. Expected a whole number of periods, 1 or greater."
1060 )),
1061 Ok(periods) => Ok(periods),
1062 }
1063}
1064
1065fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Option<String> {
1068 let (shared, supported) = match command {
1069 None => (root_shared, true),
1070 Some(Command::All(args)) => (&args.shared, args.kind != AgentReportKind::Session),
1071 Some(Command::Daily(args)) => (&args.shared, true),
1072 Some(Command::Monthly(shared)) => (shared, true),
1073 Some(Command::Weekly(args)) => (&args.shared, true),
1074 Some(Command::Session(args)) => (&args.shared, false),
1075 Some(Command::Blocks(args)) => (&args.shared, false),
1076 Some(Command::Statusline(_)) => (root_shared, false),
1077 Some(
1078 Command::Codex(args)
1079 | Command::OpenCode(args)
1080 | Command::Amp(args)
1081 | Command::Droid(args)
1082 | Command::Codebuff(args)
1083 | Command::Hermes(args)
1084 | Command::Pi(args)
1085 | Command::Goose(args)
1086 | Command::Kilo(args)
1087 | Command::Copilot(args)
1088 | Command::Gemini(args)
1089 | Command::Antigravity(args)
1090 | Command::Kimi(args)
1091 | Command::Qwen(args)
1092 | Command::OpenClaw(args)
1093 | Command::Grok(args)
1094 | Command::ZCode(args)
1095 | Command::ClaudeScience(args)
1096 | Command::OpenHands(args),
1097 ) => (&args.shared, args.kind != AgentReportKind::Session),
1098 };
1099 shared.last?;
1100 if !supported {
1101 return Some(
1102 "The --last option is only available for the daily, weekly, and monthly reports."
1103 .to_string(),
1104 );
1105 }
1106 if shared.since.is_some() || shared.until.is_some() {
1107 return Some("The --last option cannot be combined with --since or --until.".to_string());
1108 }
1109 if matches!(command, Some(Command::All(args)) if args.sections.is_some()) {
1110 return Some("The --last option cannot be used with --sections.".to_string());
1111 }
1112 None
1113}
1114
1115fn parse_cost_mode(value: &str) -> Result<CostMode, String> {
1116 match value {
1117 "auto" => Ok(CostMode::Auto),
1118 "calculate" => Ok(CostMode::Calculate),
1119 "display" => Ok(CostMode::Display),
1120 _ => Err(format!("Invalid cost mode '{value}'")),
1121 }
1122}
1123
1124fn parse_sort_order(value: &str) -> Result<SortOrder, String> {
1125 match value {
1126 "asc" => Ok(SortOrder::Asc),
1127 "desc" => Ok(SortOrder::Desc),
1128 _ => Err(format!("Invalid sort order '{value}'")),
1129 }
1130}
1131
1132fn parse_report_sections(value: &str) -> Result<Vec<AgentReportKind>, String> {
1133 let mut sections = Vec::new();
1134 for token in value.split(',') {
1135 let token = token.trim();
1136 if token.is_empty() {
1137 continue;
1138 }
1139 let kind = match token {
1140 "daily" => AgentReportKind::Daily,
1141 "weekly" => AgentReportKind::Weekly,
1142 "monthly" => AgentReportKind::Monthly,
1143 "session" => AgentReportKind::Session,
1144 _ => {
1145 return Err(format!(
1146 "Invalid --sections value '{token}'. Expected one or more of: daily, weekly, monthly, session."
1147 ));
1148 }
1149 };
1150 if !sections.contains(&kind) {
1151 sections.push(kind);
1152 }
1153 }
1154 if sections.is_empty() {
1155 return Err(format!(
1156 "Invalid --sections value '{value}'. Expected one or more of: daily, weekly, monthly, session."
1157 ));
1158 }
1159 Ok(sections)
1160}
1161
1162fn parse_week_day(value: &str) -> Result<WeekDay, String> {
1163 match value {
1164 "sunday" => Ok(WeekDay::Sunday),
1165 "monday" => Ok(WeekDay::Monday),
1166 "tuesday" => Ok(WeekDay::Tuesday),
1167 "wednesday" => Ok(WeekDay::Wednesday),
1168 "thursday" => Ok(WeekDay::Thursday),
1169 "friday" => Ok(WeekDay::Friday),
1170 "saturday" => Ok(WeekDay::Saturday),
1171 _ => Err(format!("Invalid week day '{value}'")),
1172 }
1173}
1174
1175fn parse_codex_speed(value: &str) -> Result<CodexSpeed, String> {
1176 match value {
1177 "auto" => Ok(CodexSpeed::Auto),
1178 "standard" => Ok(CodexSpeed::Standard),
1179 "fast" => Ok(CodexSpeed::Fast),
1180 _ => Err(format!("Invalid speed option '{value}'")),
1181 }
1182}
1183
1184fn parse_visual_burn_rate(value: &str) -> Result<VisualBurnRate, String> {
1185 match value {
1186 "off" => Ok(VisualBurnRate::Off),
1187 "emoji" => Ok(VisualBurnRate::Emoji),
1188 "text" => Ok(VisualBurnRate::Text),
1189 "emoji-text" => Ok(VisualBurnRate::EmojiText),
1190 _ => Err(format!("Invalid visual burn rate '{value}'")),
1191 }
1192}
1193
1194fn parse_cost_source(value: &str) -> Result<CostSource, String> {
1195 match value {
1196 "auto" => Ok(CostSource::Auto),
1197 "ccusage" => Ok(CostSource::Ccusage),
1198 "cc" => Ok(CostSource::Cc),
1199 "both" => Ok(CostSource::Both),
1200 _ => Err(format!("Invalid cost source '{value}'")),
1201 }
1202}
1203
1204#[cfg(test)]
1205mod tests {
1206 use super::*;
1207
1208 fn args(values: &[&str]) -> Vec<String> {
1209 values.iter().map(|value| value.to_string()).collect()
1210 }
1211
1212 #[test]
1213 fn detects_help_before_semantic_validation() {
1214 assert_eq!(
1215 control_arg(&args(&["--help", "--daily"])),
1216 Some(ControlArg::Help)
1217 );
1218 }
1219
1220 #[test]
1221 fn version_takes_precedence_over_help() {
1222 assert_eq!(
1223 control_arg(&args(&["--help", "--version"])),
1224 Some(ControlArg::Version)
1225 );
1226 }
1227}