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