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