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