1use clap::error::ErrorKind;
4use clap::{Parser, Subcommand};
5use std::collections::HashSet;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct ExtensionCliFlag {
9 pub name: String,
10 pub value: Option<String>,
11}
12
13impl ExtensionCliFlag {
14 pub fn display_name(&self) -> String {
15 format!("--{}", self.name)
16 }
17}
18
19#[derive(Debug)]
20pub struct ParsedCli {
21 pub cli: Cli,
22 pub extension_flags: Vec<ExtensionCliFlag>,
23}
24
25#[derive(Debug, Clone, Copy)]
26struct LongOptionSpec {
27 takes_value: bool,
28 optional_value: bool,
29}
30
31const ROOT_SUBCOMMANDS: &[&str] = &[
32 "install",
33 "remove",
34 "update",
35 "update-index",
36 "search",
37 "info",
38 "list",
39 "config",
40 "doctor",
41 "migrate",
42];
43
44fn known_long_option(name: &str) -> Option<LongOptionSpec> {
45 let (takes_value, optional_value) = match name {
46 "version"
47 | "continue"
48 | "resume"
49 | "no-session"
50 | "no-migrations"
51 | "print"
52 | "acp"
53 | "verbose"
54 | "no-tools"
55 | "no-extensions"
56 | "explain-extension-policy"
57 | "explain-repair-policy"
58 | "no-skills"
59 | "no-prompt-templates"
60 | "no-themes"
61 | "list-providers"
62 | "hide-cwd-in-prompt" => (false, false),
63 "provider"
64 | "model"
65 | "api-key"
66 | "models"
67 | "thinking"
68 | "system-prompt"
69 | "append-system-prompt"
70 | "session"
71 | "session-dir"
72 | "session-durability"
73 | "mode"
74 | "tools"
75 | "extension"
76 | "extension-policy"
77 | "repair-policy"
78 | "skill"
79 | "prompt-template"
80 | "theme"
81 | "theme-path"
82 | "export" => (true, false),
83 "list-models" => (true, true),
84 _ => return None,
85 };
86 Some(LongOptionSpec {
87 takes_value,
88 optional_value,
89 })
90}
91
92fn is_known_short_flag(token: &str) -> bool {
93 if !token.starts_with('-') || token.starts_with("--") {
94 return false;
95 }
96 let body = &token[1..];
97 if body.is_empty() {
98 return false;
99 }
100 body.chars()
101 .all(|ch| matches!(ch, 'v' | 'c' | 'r' | 'p' | 'e'))
102}
103
104fn short_flag_expects_value(token: &str) -> bool {
105 if !is_known_short_flag(token) {
106 return false;
107 }
108
109 let body = &token[1..];
110 body.find('e').is_some_and(|index| index == body.len() - 1)
111}
112
113fn is_negative_numeric_token(token: &str) -> bool {
114 if !token.starts_with('-') || token == "-" || token.starts_with("--") {
115 return false;
116 }
117 token.parse::<i64>().is_ok() || token.parse::<f64>().is_ok_and(f64::is_finite)
118}
119
120#[allow(clippy::too_many_lines)] fn preprocess_extension_flags(raw_args: &[String]) -> (Vec<String>, Vec<ExtensionCliFlag>) {
122 if raw_args.is_empty() {
123 return (vec!["pi".to_string()], Vec::new());
124 }
125 let mut filtered = Vec::with_capacity(raw_args.len());
126 filtered.push(raw_args[0].clone());
127 let mut extracted = Vec::new();
128 let mut expecting_value = false;
129 let mut in_subcommand = false;
130 let mut index = 1usize;
131 while index < raw_args.len() {
132 let token = &raw_args[index];
133 if token == "--" {
134 filtered.extend(raw_args[index..].iter().cloned());
135 break;
136 }
137 if expecting_value {
138 filtered.push(token.clone());
139 expecting_value = false;
140 index += 1;
141 continue;
142 }
143 if in_subcommand {
144 filtered.push(token.clone());
145 index += 1;
146 continue;
147 }
148 if token.starts_with("--") && token.len() > 2 {
149 let without_prefix = &token[2..];
150 let (name, has_inline_value) = without_prefix
151 .split_once('=')
152 .map_or((without_prefix, false), |(name, _)| (name, true));
153 if let Some(spec) = known_long_option(name) {
154 filtered.push(token.clone());
155 if spec.takes_value && !has_inline_value && !spec.optional_value {
156 expecting_value = true;
157 } else if spec.takes_value && !has_inline_value && spec.optional_value {
158 let has_value = raw_args
159 .get(index + 1)
160 .is_some_and(|next| !next.starts_with('-') || next == "-");
161 expecting_value = has_value;
162 }
163 index += 1;
164 continue;
165 }
166 let (name, inline_value) = without_prefix
167 .split_once('=')
168 .map_or((without_prefix, None), |(name, value)| {
169 (name, Some(value.to_string()))
170 });
171 if name.is_empty() {
172 filtered.push(token.clone());
173 index += 1;
174 continue;
175 }
176 let mut value = inline_value;
177 if value.is_none() {
178 let next = raw_args.get(index + 1);
179 if let Some(next) = next {
180 if next != "--"
181 && (!next.starts_with('-')
182 || next == "-"
183 || is_negative_numeric_token(next))
184 {
185 value = Some(next.clone());
186 index += 1;
187 }
188 }
189 }
190 extracted.push(ExtensionCliFlag {
191 name: name.to_string(),
192 value,
193 });
194 index += 1;
195 continue;
196 }
197 if token == "-e" {
198 filtered.push(token.clone());
199 expecting_value = true;
200 index += 1;
201 continue;
202 }
203 if is_known_short_flag(token) {
204 filtered.push(token.clone());
205 expecting_value = short_flag_expects_value(token);
206 index += 1;
207 continue;
208 }
209 if token.starts_with('-') {
210 filtered.push(token.clone());
211 index += 1;
212 continue;
213 }
214 if ROOT_SUBCOMMANDS.contains(&token.as_str()) {
215 in_subcommand = true;
216 }
217 filtered.push(token.clone());
218 index += 1;
219 }
220 (filtered, extracted)
221}
222
223pub fn parse_with_extension_flags(raw_args: Vec<String>) -> Result<ParsedCli, clap::Error> {
224 if raw_args.is_empty() {
225 let cli = Cli::try_parse_from(["pi"])?;
226 return Ok(ParsedCli {
227 cli,
228 extension_flags: Vec::new(),
229 });
230 }
231
232 match Cli::try_parse_from(raw_args.clone()) {
233 Ok(_) => {
234 }
238 Err(err) => {
239 if matches!(
240 err.kind(),
241 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
242 ) {
243 return Err(err);
244 }
245 }
246 }
247
248 let (filtered_args, extension_flags) = preprocess_extension_flags(&raw_args);
249 if extension_flags.is_empty() {
250 let cli = Cli::try_parse_from(raw_args)?;
251 return Ok(ParsedCli {
252 cli,
253 extension_flags: Vec::new(),
254 });
255 }
256
257 let cli = Cli::try_parse_from(filtered_args)?;
258 Ok(ParsedCli {
259 cli,
260 extension_flags,
261 })
262}
263
264#[derive(Parser, Debug)]
266#[allow(clippy::struct_excessive_bools)] #[command(name = "pi")]
268#[command(version, about, long_about = None, disable_version_flag = true)]
269#[command(after_help = "Examples:
270 pi \"explain this code\" Start new session with message
271 pi @file.rs \"review this\" Include file in context
272 pi -c Continue previous session
273 pi -r Resume from session picker
274 pi -p \"what is 2+2\" Print mode (non-interactive)
275 pi --model claude-opus-4 \"help\" Use specific model
276")]
277pub struct Cli {
278 #[arg(short = 'v', long)]
281 pub version: bool,
282
283 #[arg(long, env = "PI_PROVIDER")]
287 pub provider: Option<String>,
288
289 #[arg(long, env = "PI_MODEL")]
291 pub model: Option<String>,
292
293 #[arg(long)]
295 pub api_key: Option<String>,
296
297 #[arg(long)]
299 pub models: Option<String>,
300
301 #[arg(long, value_parser = ["off", "minimal", "low", "medium", "high", "xhigh"])]
304 pub thinking: Option<String>,
305
306 #[arg(long)]
309 pub system_prompt: Option<String>,
310
311 #[arg(long)]
313 pub append_system_prompt: Option<String>,
314
315 #[arg(short = 'c', long)]
318 pub r#continue: bool,
319
320 #[arg(short = 'r', long)]
322 pub resume: bool,
323
324 #[arg(long)]
326 pub session: Option<String>,
327
328 #[arg(long)]
330 pub session_dir: Option<String>,
331
332 #[arg(long)]
334 pub no_session: bool,
335
336 #[arg(
338 long,
339 value_parser = ["strict", "balanced", "throughput"]
340 )]
341 pub session_durability: Option<String>,
342
343 #[arg(long)]
345 pub no_migrations: bool,
346
347 #[arg(long, value_parser = ["text", "json", "rpc"])]
350 pub mode: Option<String>,
351
352 #[arg(short = 'p', long)]
354 pub print: bool,
355
356 #[arg(long)]
359 pub acp: bool,
360
361 #[arg(long)]
363 pub verbose: bool,
364
365 #[arg(long)]
368 pub no_tools: bool,
369
370 #[arg(
372 long,
373 default_value = "read,bash,edit,write,grep,find,ls,hashline_edit"
374 )]
375 pub tools: String,
376
377 #[arg(short = 'e', long, action = clap::ArgAction::Append)]
380 pub extension: Vec<String>,
381
382 #[arg(long)]
384 pub no_extensions: bool,
385
386 #[arg(long, value_name = "PROFILE")]
388 pub extension_policy: Option<String>,
389
390 #[arg(long)]
392 pub explain_extension_policy: bool,
393
394 #[arg(long, value_name = "MODE")]
396 pub repair_policy: Option<String>,
397
398 #[arg(long)]
400 pub explain_repair_policy: bool,
401
402 #[arg(long, action = clap::ArgAction::Append)]
405 pub skill: Vec<String>,
406
407 #[arg(long)]
409 pub no_skills: bool,
410
411 #[arg(long, action = clap::ArgAction::Append)]
414 pub prompt_template: Vec<String>,
415
416 #[arg(long)]
418 pub no_prompt_templates: bool,
419
420 #[arg(long)]
423 pub theme: Option<String>,
424
425 #[arg(long = "theme-path", action = clap::ArgAction::Append)]
427 pub theme_path: Vec<String>,
428
429 #[arg(long)]
431 pub no_themes: bool,
432
433 #[arg(long, env = "PI_HIDE_CWD_IN_PROMPT")]
436 pub hide_cwd_in_prompt: bool,
437
438 #[arg(long)]
441 pub export: Option<String>,
442
443 #[arg(long)]
445 #[allow(clippy::option_option)]
446 pub list_models: Option<Option<String>>,
448
449 #[arg(long)]
451 pub list_providers: bool,
452
453 #[command(subcommand)]
455 pub command: Option<Commands>,
456
457 #[arg(trailing_var_arg = true)]
460 pub args: Vec<String>,
461}
462
463#[cfg(test)]
464mod tests {
465 use super::{Cli, Commands, ROOT_SUBCOMMANDS, parse_with_extension_flags};
466 use clap::{CommandFactory, Parser};
467
468 #[test]
471 fn parse_resource_flags_and_mode() {
472 let cli = Cli::parse_from([
473 "pi",
474 "--mode",
475 "rpc",
476 "--models",
477 "gpt-4*,claude*",
478 "--extension",
479 "ext1",
480 "--skill",
481 "skill.md",
482 "--prompt-template",
483 "prompt.md",
484 "--theme",
485 "dark",
486 "--theme-path",
487 "dark.ini",
488 "--no-themes",
489 ]);
490
491 assert_eq!(cli.mode.as_deref(), Some("rpc"));
492 assert_eq!(cli.models.as_deref(), Some("gpt-4*,claude*"));
493 assert_eq!(cli.extension, vec!["ext1".to_string()]);
494 assert_eq!(cli.skill, vec!["skill.md".to_string()]);
495 assert_eq!(cli.prompt_template, vec!["prompt.md".to_string()]);
496 assert_eq!(cli.theme.as_deref(), Some("dark"));
497 assert_eq!(cli.theme_path, vec!["dark.ini".to_string()]);
498 assert!(cli.no_themes);
499 }
500
501 #[test]
502 fn parse_continue_short_flag() {
503 let cli = Cli::parse_from(["pi", "-c"]);
504 assert!(cli.r#continue);
505 assert!(!cli.resume);
506 assert!(!cli.print);
507 }
508
509 #[test]
510 fn parse_continue_long_flag() {
511 let cli = Cli::parse_from(["pi", "--continue"]);
512 assert!(cli.r#continue);
513 }
514
515 #[test]
516 fn parse_resume_short_flag() {
517 let cli = Cli::parse_from(["pi", "-r"]);
518 assert!(cli.resume);
519 assert!(!cli.r#continue);
520 }
521
522 #[test]
523 fn parse_session_path() {
524 let cli = Cli::parse_from(["pi", "--session", "/tmp/session.jsonl"]);
525 assert_eq!(cli.session.as_deref(), Some("/tmp/session.jsonl"));
526 }
527
528 #[test]
529 fn parse_session_dir() {
530 let cli = Cli::parse_from(["pi", "--session-dir", "/tmp/sessions"]);
531 assert_eq!(cli.session_dir.as_deref(), Some("/tmp/sessions"));
532 }
533
534 #[test]
535 fn parse_no_session() {
536 let cli = Cli::parse_from(["pi", "--no-session"]);
537 assert!(cli.no_session);
538 }
539
540 #[test]
541 fn parse_session_durability() {
542 let cli = Cli::parse_from(["pi", "--session-durability", "throughput"]);
543 assert_eq!(cli.session_durability.as_deref(), Some("throughput"));
544 }
545
546 #[test]
547 fn parse_no_migrations() {
548 let cli = Cli::parse_from(["pi", "--no-migrations"]);
549 assert!(cli.no_migrations);
550 }
551
552 #[test]
553 fn parse_print_short_flag() {
554 let cli = Cli::parse_from(["pi", "-p", "what is 2+2"]);
555 assert!(cli.print);
556 assert_eq!(cli.message_args(), vec!["what is 2+2"]);
557 }
558
559 #[test]
560 fn parse_print_long_flag() {
561 let cli = Cli::parse_from(["pi", "--print", "question"]);
562 assert!(cli.print);
563 }
564
565 #[test]
566 fn parse_model_flag() {
567 let cli = Cli::parse_from(["pi", "--model", "claude-opus-4"]);
568 assert_eq!(cli.model.as_deref(), Some("claude-opus-4"));
569 }
570
571 #[test]
572 fn parse_provider_flag() {
573 let cli = Cli::parse_from(["pi", "--provider", "openai"]);
574 assert_eq!(cli.provider.as_deref(), Some("openai"));
575 }
576
577 #[test]
578 fn parse_api_key_flag() {
579 let cli = Cli::parse_from(["pi", "--api-key", "sk-ant-test123"]);
580 assert_eq!(cli.api_key.as_deref(), Some("sk-ant-test123"));
581 }
582
583 #[test]
584 fn parse_version_short_flag() {
585 let cli = Cli::parse_from(["pi", "-v"]);
586 assert!(cli.version);
587 }
588
589 #[test]
590 fn parse_version_long_flag() {
591 let cli = Cli::parse_from(["pi", "--version"]);
592 assert!(cli.version);
593 }
594
595 #[test]
596 fn parse_with_extension_flags_preserves_help_error() {
597 let err = parse_with_extension_flags(vec!["pi".into(), "--help".into()])
598 .expect_err("`--help` should stay a clap help path");
599 assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
600 }
601
602 #[test]
603 fn parse_verbose_flag() {
604 let cli = Cli::parse_from(["pi", "--verbose"]);
605 assert!(cli.verbose);
606 }
607
608 #[test]
609 fn parse_system_prompt_flags() {
610 let cli = Cli::parse_from([
611 "pi",
612 "--system-prompt",
613 "You are a helper",
614 "--append-system-prompt",
615 "Be concise",
616 ]);
617 assert_eq!(cli.system_prompt.as_deref(), Some("You are a helper"));
618 assert_eq!(cli.append_system_prompt.as_deref(), Some("Be concise"));
619 }
620
621 #[test]
622 fn parse_export_flag() {
623 let cli = Cli::parse_from(["pi", "--export", "output.html"]);
624 assert_eq!(cli.export.as_deref(), Some("output.html"));
625 }
626
627 #[test]
630 fn parse_all_thinking_levels() {
631 for level in &["off", "minimal", "low", "medium", "high", "xhigh"] {
632 let cli = Cli::parse_from(["pi", "--thinking", level]);
633 assert_eq!(cli.thinking.as_deref(), Some(*level));
634 }
635 }
636
637 #[test]
638 fn invalid_thinking_level_rejected() {
639 let result = Cli::try_parse_from(["pi", "--thinking", "ultra"]);
640 assert!(result.is_err());
641 }
642
643 #[test]
646 fn file_and_message_args_split() {
647 let cli = Cli::parse_from(["pi", "@a.txt", "hello", "@b.md", "world"]);
648 assert_eq!(cli.file_args(), vec!["a.txt", "b.md"]);
649 assert_eq!(cli.message_args(), vec!["hello", "world"]);
650 }
651
652 #[test]
653 fn file_args_empty_when_none() {
654 let cli = Cli::parse_from(["pi", "hello", "world"]);
655 assert!(cli.file_args().is_empty());
656 assert_eq!(cli.message_args(), vec!["hello", "world"]);
657 }
658
659 #[test]
660 fn message_args_empty_when_only_files() {
661 let cli = Cli::parse_from(["pi", "@src/main.rs", "@Cargo.toml"]);
662 assert_eq!(cli.file_args(), vec!["src/main.rs", "Cargo.toml"]);
663 assert!(cli.message_args().is_empty());
664 }
665
666 #[test]
667 fn no_positional_args_yields_empty() {
668 let cli = Cli::parse_from(["pi"]);
669 assert!(cli.file_args().is_empty());
670 assert!(cli.message_args().is_empty());
671 }
672
673 #[test]
674 fn at_prefix_stripped_from_file_paths() {
675 let cli = Cli::parse_from(["pi", "@/absolute/path.rs"]);
676 assert_eq!(cli.file_args(), vec!["/absolute/path.rs"]);
677 }
678
679 #[test]
682 fn parse_install_subcommand() {
683 let cli = Cli::parse_from(["pi", "install", "npm:@org/pkg"]);
684 match cli.command {
685 Some(Commands::Install { source, local }) => {
686 assert_eq!(source, "npm:@org/pkg");
687 assert!(!local);
688 }
689 other => panic!("unexpected command: {:?}", other),
690 }
691 }
692
693 #[test]
694 fn parse_install_local_flag() {
695 let cli = Cli::parse_from(["pi", "install", "--local", "git:https://example.com"]);
696 match cli.command {
697 Some(Commands::Install { source, local }) => {
698 assert_eq!(source, "git:https://example.com");
699 assert!(local);
700 }
701 other => panic!("unexpected command: {:?}", other),
702 }
703 }
704
705 #[test]
706 fn parse_install_local_short_flag() {
707 let cli = Cli::parse_from(["pi", "install", "-l", "./local-ext"]);
708 match cli.command {
709 Some(Commands::Install { local, .. }) => assert!(local),
710 other => panic!("unexpected command: {:?}", other),
711 }
712 }
713
714 #[test]
715 fn parse_remove_subcommand() {
716 let cli = Cli::parse_from(["pi", "remove", "npm:pkg"]);
717 match cli.command {
718 Some(Commands::Remove { source, local }) => {
719 assert_eq!(source, "npm:pkg");
720 assert!(!local);
721 }
722 other => panic!("unexpected command: {:?}", other),
723 }
724 }
725
726 #[test]
727 fn parse_remove_local_flag() {
728 let cli = Cli::parse_from(["pi", "remove", "--local", "npm:pkg"]);
729 match cli.command {
730 Some(Commands::Remove { local, .. }) => assert!(local),
731 other => panic!("unexpected command: {:?}", other),
732 }
733 }
734
735 #[test]
736 fn parse_update_with_source() {
737 let cli = Cli::parse_from(["pi", "update", "npm:pkg"]);
738 match cli.command {
739 Some(Commands::Update { source }) => {
740 assert_eq!(source.as_deref(), Some("npm:pkg"));
741 }
742 other => panic!("unexpected command: {:?}", other),
743 }
744 }
745
746 #[test]
747 fn parse_update_all() {
748 let cli = Cli::parse_from(["pi", "update"]);
749 match cli.command {
750 Some(Commands::Update { source }) => assert!(source.is_none()),
751 other => panic!("unexpected command: {:?}", other),
752 }
753 }
754
755 #[test]
756 fn parse_list_subcommand() {
757 let cli = Cli::parse_from(["pi", "list"]);
758 assert!(matches!(cli.command, Some(Commands::List)));
759 }
760
761 #[test]
762 fn parse_config_subcommand() {
763 let cli = Cli::parse_from(["pi", "config"]);
764 match cli.command {
765 Some(Commands::Config { show, paths, json }) => {
766 assert!(!show);
767 assert!(!paths);
768 assert!(!json);
769 }
770 other => panic!("unexpected command: {:?}", other),
771 }
772 }
773
774 #[test]
775 fn parse_config_show_flag() {
776 let cli = Cli::parse_from(["pi", "config", "--show"]);
777 match cli.command {
778 Some(Commands::Config { show, paths, json }) => {
779 assert!(show);
780 assert!(!paths);
781 assert!(!json);
782 }
783 other => panic!("unexpected command: {:?}", other),
784 }
785 }
786
787 #[test]
788 fn parse_config_paths_flag() {
789 let cli = Cli::parse_from(["pi", "config", "--paths"]);
790 match cli.command {
791 Some(Commands::Config { show, paths, json }) => {
792 assert!(!show);
793 assert!(paths);
794 assert!(!json);
795 }
796 other => panic!("unexpected command: {:?}", other),
797 }
798 }
799
800 #[test]
801 fn parse_config_json_flag() {
802 let cli = Cli::parse_from(["pi", "config", "--json"]);
803 match cli.command {
804 Some(Commands::Config { show, paths, json }) => {
805 assert!(!show);
806 assert!(!paths);
807 assert!(json);
808 }
809 other => panic!("unexpected command: {:?}", other),
810 }
811 }
812
813 #[test]
814 fn parse_update_index_subcommand() {
815 let cli = Cli::parse_from(["pi", "update-index"]);
816 assert!(matches!(cli.command, Some(Commands::UpdateIndex)));
817 }
818
819 #[test]
820 fn parse_info_subcommand() {
821 let cli = Cli::parse_from(["pi", "info", "auto-commit-on-exit"]);
822 match cli.command {
823 Some(Commands::Info { name }) => {
824 assert_eq!(name, "auto-commit-on-exit");
825 }
826 other => panic!("unexpected command: {:?}", other),
827 }
828 }
829
830 #[test]
831 fn no_subcommand_when_only_message() {
832 let cli = Cli::parse_from(["pi", "hello"]);
833 assert!(cli.command.is_none());
834 assert_eq!(cli.message_args(), vec!["hello"]);
835 }
836
837 #[test]
840 fn list_models_not_set() {
841 let cli = Cli::parse_from(["pi"]);
842 assert!(cli.list_models.is_none());
843 }
844
845 #[test]
846 fn list_models_without_pattern() {
847 let cli = Cli::parse_from(["pi", "--list-models"]);
848 assert!(matches!(cli.list_models, Some(None)));
849 }
850
851 #[test]
852 fn list_models_with_pattern() {
853 let cli = Cli::parse_from(["pi", "--list-models", "claude*"]);
854 match cli.list_models {
855 Some(Some(ref pat)) => assert_eq!(pat, "claude*"),
856 other => panic!("unexpected command: {:?}", other),
857 }
858 }
859
860 #[test]
863 fn list_providers_not_set() {
864 let cli = Cli::parse_from(["pi"]);
865 assert!(!cli.list_providers);
866 }
867
868 #[test]
869 fn list_providers_set() {
870 let cli = Cli::parse_from(["pi", "--list-providers"]);
871 assert!(cli.list_providers);
872 }
873
874 #[test]
877 fn default_tools() {
878 let cli = Cli::parse_from(["pi"]);
879 assert_eq!(
880 cli.enabled_tools(),
881 vec![
882 "read",
883 "bash",
884 "edit",
885 "write",
886 "grep",
887 "find",
888 "ls",
889 "hashline_edit",
890 ]
891 );
892 }
893
894 #[test]
895 fn custom_tools_list() {
896 let cli = Cli::parse_from(["pi", "--tools", "read,grep,find,ls"]);
897 assert_eq!(cli.enabled_tools(), vec!["read", "grep", "find", "ls"]);
898 }
899
900 #[test]
901 fn no_tools_flag_returns_empty() {
902 let cli = Cli::parse_from(["pi", "--no-tools"]);
903 assert!(cli.enabled_tools().is_empty());
904 }
905
906 #[test]
907 fn tools_with_spaces_trimmed() {
908 let cli = Cli::parse_from(["pi", "--tools", "read, bash, edit"]);
909 assert_eq!(cli.enabled_tools(), vec!["read", "bash", "edit"]);
910 }
911
912 #[test]
913 fn tools_ignore_empty_entries_and_duplicates() {
914 let cli = Cli::parse_from(["pi", "--tools", "read,, bash,read, ,grep,bash"]);
915 assert_eq!(cli.enabled_tools(), vec!["read", "bash", "grep"]);
916 }
917
918 #[test]
921 fn unknown_flag_rejected() {
922 let result = Cli::try_parse_from(["pi", "--nonexistent"]);
923 assert!(result.is_err());
924 }
925
926 #[test]
927 fn invalid_mode_rejected() {
928 let result = Cli::try_parse_from(["pi", "--mode", "xml"]);
929 assert!(result.is_err());
930 }
931
932 #[test]
933 fn install_without_source_rejected() {
934 let result = Cli::try_parse_from(["pi", "install"]);
935 assert!(result.is_err());
936 }
937
938 #[test]
939 fn remove_without_source_rejected() {
940 let result = Cli::try_parse_from(["pi", "remove"]);
941 assert!(result.is_err());
942 }
943
944 #[test]
945 fn invalid_subcommand_option_rejected() {
946 let result = Cli::try_parse_from(["pi", "install", "--bogus", "npm:pkg"]);
947 assert!(result.is_err());
948 }
949
950 #[test]
951 fn extension_flags_are_extracted_in_second_pass_parse() {
952 let parsed = parse_with_extension_flags(vec![
953 "pi".to_string(),
954 "--plan".to_string(),
955 "ship it".to_string(),
956 "--model".to_string(),
957 "gpt-4o".to_string(),
958 ])
959 .expect("parse with extension flags");
960
961 assert_eq!(parsed.cli.model.as_deref(), Some("gpt-4o"));
962 assert_eq!(parsed.extension_flags.len(), 1);
963 assert_eq!(parsed.extension_flags[0].name, "plan");
964 assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("ship it"));
965 }
966
967 #[test]
968 fn extension_bool_flag_without_value_is_supported() {
969 let parsed = parse_with_extension_flags(vec![
970 "pi".to_string(),
971 "--dry-run".to_string(),
972 "--print".to_string(),
973 "hello".to_string(),
974 ])
975 .expect("parse extension bool flag");
976
977 assert!(parsed.cli.print);
978 assert_eq!(parsed.extension_flags.len(), 1);
979 assert_eq!(parsed.extension_flags[0].name, "dry-run");
980 assert!(parsed.extension_flags[0].value.is_none());
981 }
982
983 #[test]
984 fn extension_flag_accepts_negative_integer_value() {
985 let parsed = parse_with_extension_flags(vec![
986 "pi".to_string(),
987 "--temperature".to_string(),
988 "-1".to_string(),
989 "--print".to_string(),
990 "hello".to_string(),
991 ])
992 .expect("parse negative integer value");
993
994 assert!(parsed.cli.print);
995 assert_eq!(parsed.extension_flags.len(), 1);
996 assert_eq!(parsed.extension_flags[0].name, "temperature");
997 assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("-1"));
998 }
999
1000 #[test]
1001 fn extension_flag_accepts_negative_float_value() {
1002 let parsed = parse_with_extension_flags(vec![
1003 "pi".to_string(),
1004 "--temperature".to_string(),
1005 "-0.25".to_string(),
1006 "--print".to_string(),
1007 "hello".to_string(),
1008 ])
1009 .expect("parse negative float value");
1010
1011 assert!(parsed.cli.print);
1012 assert_eq!(parsed.extension_flags.len(), 1);
1013 assert_eq!(parsed.extension_flags[0].name, "temperature");
1014 assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("-0.25"));
1015 }
1016
1017 #[test]
1018 fn parse_with_extension_flags_recognizes_session_durability_as_builtin() {
1019 let parsed = parse_with_extension_flags(vec![
1020 "pi".to_string(),
1021 "--session-durability".to_string(),
1022 "throughput".to_string(),
1023 "--print".to_string(),
1024 "hello".to_string(),
1025 ])
1026 .expect("parse with session durability");
1027
1028 assert_eq!(parsed.cli.session_durability.as_deref(), Some("throughput"));
1029 assert!(parsed.extension_flags.is_empty());
1030 assert!(parsed.cli.print);
1031 }
1032
1033 #[test]
1034 fn extension_flag_parser_does_not_bypass_subcommand_validation() {
1035 let result = parse_with_extension_flags(vec![
1036 "pi".to_string(),
1037 "install".to_string(),
1038 "--bogus".to_string(),
1039 "pkg".to_string(),
1040 ]);
1041 assert!(result.is_err());
1042 }
1043
1044 #[test]
1045 fn extension_flags_survive_short_cluster_ending_in_e() {
1046 let parsed = parse_with_extension_flags(vec![
1047 "pi".to_string(),
1048 "-pe".to_string(),
1049 "ext.js".to_string(),
1050 "--plan".to_string(),
1051 "ship-it".to_string(),
1052 "hello".to_string(),
1053 ])
1054 .expect("parse short cluster with extension");
1055
1056 assert!(parsed.cli.print);
1057 assert_eq!(parsed.cli.extension, vec!["ext.js".to_string()]);
1058 assert_eq!(parsed.cli.message_args(), vec!["hello"]);
1059 assert_eq!(parsed.extension_flags.len(), 1);
1060 assert_eq!(parsed.extension_flags[0].name, "plan");
1061 assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("ship-it"));
1062 }
1063
1064 #[test]
1065 fn extension_flags_after_message_args_are_extracted() {
1066 let parsed = parse_with_extension_flags(vec![
1067 "pi".to_string(),
1068 "hello".to_string(),
1069 "--plan".to_string(),
1070 "ship-it".to_string(),
1071 ])
1072 .expect("parse extension flag after message");
1073
1074 assert_eq!(parsed.cli.message_args(), vec!["hello"]);
1075 assert_eq!(parsed.extension_flags.len(), 1);
1076 assert_eq!(parsed.extension_flags[0].name, "plan");
1077 assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("ship-it"));
1078 }
1079
1080 #[test]
1081 fn extension_flag_inline_value_matches_separate_value() {
1082 let separate = parse_with_extension_flags(vec![
1083 "pi".to_string(),
1084 "--plan".to_string(),
1085 "ship-it".to_string(),
1086 "--print".to_string(),
1087 "hello".to_string(),
1088 ])
1089 .expect("parse separate extension flag");
1090
1091 let inline = parse_with_extension_flags(vec![
1092 "pi".to_string(),
1093 "--plan=ship-it".to_string(),
1094 "--print".to_string(),
1095 "hello".to_string(),
1096 ])
1097 .expect("parse inline extension flag");
1098
1099 assert_eq!(separate.cli.print, inline.cli.print);
1100 assert_eq!(separate.cli.message_args(), inline.cli.message_args());
1101 assert_eq!(separate.extension_flags, inline.extension_flags);
1102 }
1103
1104 #[test]
1105 fn root_subcommands_constant_matches_clap_parser() {
1106 let mut actual = Cli::command()
1107 .get_subcommands()
1108 .map(|command| command.get_name().to_string())
1109 .collect::<Vec<_>>();
1110 actual.sort();
1111
1112 let mut expected = ROOT_SUBCOMMANDS
1113 .iter()
1114 .map(|name| (*name).to_string())
1115 .collect::<Vec<_>>();
1116 expected.sort();
1117
1118 assert_eq!(expected, actual);
1119 }
1120
1121 #[test]
1124 fn multiple_extensions() {
1125 let cli = Cli::parse_from([
1126 "pi",
1127 "--extension",
1128 "ext1.js",
1129 "-e",
1130 "ext2.js",
1131 "--extension",
1132 "ext3.js",
1133 ]);
1134 assert_eq!(
1135 cli.extension,
1136 vec!["ext1.js", "ext2.js", "ext3.js"]
1137 .into_iter()
1138 .map(String::from)
1139 .collect::<Vec<_>>()
1140 );
1141 }
1142
1143 #[test]
1144 fn multiple_skills() {
1145 let cli = Cli::parse_from(["pi", "--skill", "a.md", "--skill", "b.md"]);
1146 assert_eq!(
1147 cli.skill,
1148 vec!["a.md", "b.md"]
1149 .into_iter()
1150 .map(String::from)
1151 .collect::<Vec<_>>()
1152 );
1153 }
1154
1155 #[test]
1156 fn multiple_theme_paths() {
1157 let cli = Cli::parse_from(["pi", "--theme-path", "a/", "--theme-path", "b/"]);
1158 assert_eq!(
1159 cli.theme_path,
1160 vec!["a/", "b/"]
1161 .into_iter()
1162 .map(String::from)
1163 .collect::<Vec<_>>()
1164 );
1165 }
1166
1167 #[test]
1170 fn no_extensions_flag() {
1171 let cli = Cli::parse_from(["pi", "--no-extensions"]);
1172 assert!(cli.no_extensions);
1173 }
1174
1175 #[test]
1176 fn no_skills_flag() {
1177 let cli = Cli::parse_from(["pi", "--no-skills"]);
1178 assert!(cli.no_skills);
1179 }
1180
1181 #[test]
1182 fn no_prompt_templates_flag() {
1183 let cli = Cli::parse_from(["pi", "--no-prompt-templates"]);
1184 assert!(cli.no_prompt_templates);
1185 }
1186
1187 #[test]
1190 fn bare_invocation_defaults() {
1191 let cli = Cli::parse_from(["pi"]);
1192 assert!(!cli.version);
1193 assert!(!cli.r#continue);
1194 assert!(!cli.resume);
1195 assert!(!cli.print);
1196 assert!(!cli.verbose);
1197 assert!(!cli.no_session);
1198 assert!(!cli.no_migrations);
1199 assert!(!cli.no_tools);
1200 assert!(!cli.no_extensions);
1201 assert!(!cli.no_skills);
1202 assert!(!cli.no_prompt_templates);
1203 assert!(!cli.no_themes);
1204 assert!(cli.provider.is_none());
1205 assert!(cli.model.is_none());
1206 assert!(cli.api_key.is_none());
1207 assert!(cli.thinking.is_none());
1208 assert!(cli.session.is_none());
1209 assert!(cli.session_dir.is_none());
1210 assert!(cli.mode.is_none());
1211 assert!(cli.export.is_none());
1212 assert!(cli.system_prompt.is_none());
1213 assert!(cli.append_system_prompt.is_none());
1214 assert!(cli.list_models.is_none());
1215 assert!(cli.command.is_none());
1216 assert!(cli.args.is_empty());
1217 assert_eq!(cli.tools, "read,bash,edit,write,grep,find,ls,hashline_edit");
1218 }
1219
1220 #[test]
1223 fn print_mode_with_model_and_thinking() {
1224 let cli = Cli::parse_from([
1225 "pi",
1226 "-p",
1227 "--model",
1228 "gpt-4o",
1229 "--thinking",
1230 "high",
1231 "solve this problem",
1232 ]);
1233 assert!(cli.print);
1234 assert_eq!(cli.model.as_deref(), Some("gpt-4o"));
1235 assert_eq!(cli.thinking.as_deref(), Some("high"));
1236 assert_eq!(cli.message_args(), vec!["solve this problem"]);
1237 }
1238
1239 #[test]
1242 fn extension_policy_flag_parses() {
1243 let cli = Cli::parse_from(["pi", "--extension-policy", "safe"]);
1244 assert_eq!(cli.extension_policy.as_deref(), Some("safe"));
1245 }
1246
1247 #[test]
1248 fn extension_policy_flag_permissive() {
1249 let cli = Cli::parse_from(["pi", "--extension-policy", "permissive"]);
1250 assert_eq!(cli.extension_policy.as_deref(), Some("permissive"));
1251 }
1252
1253 #[test]
1254 fn extension_policy_flag_balanced() {
1255 let cli = Cli::parse_from(["pi", "--extension-policy", "balanced"]);
1256 assert_eq!(cli.extension_policy.as_deref(), Some("balanced"));
1257 }
1258
1259 #[test]
1260 fn extension_policy_flag_absent() {
1261 let cli = Cli::parse_from(["pi"]);
1262 assert!(cli.extension_policy.is_none());
1263 }
1264
1265 #[test]
1266 fn explain_extension_policy_flag_parses() {
1267 let cli = Cli::parse_from(["pi", "--explain-extension-policy"]);
1268 assert!(cli.explain_extension_policy);
1269 }
1270
1271 #[test]
1274 fn repair_policy_flag_parses() {
1275 let cli = Cli::parse_from(["pi", "--repair-policy", "auto-safe"]);
1276 assert_eq!(cli.repair_policy.as_deref(), Some("auto-safe"));
1277 }
1278
1279 #[test]
1280 fn repair_policy_flag_off() {
1281 let cli = Cli::parse_from(["pi", "--repair-policy", "off"]);
1282 assert_eq!(cli.repair_policy.as_deref(), Some("off"));
1283 }
1284
1285 #[test]
1286 fn repair_policy_flag_absent() {
1287 let cli = Cli::parse_from(["pi"]);
1288 assert!(cli.repair_policy.is_none());
1289 }
1290
1291 #[test]
1292 fn explain_repair_policy_flag_parses() {
1293 let cli = Cli::parse_from(["pi", "--explain-repair-policy"]);
1294 assert!(cli.explain_repair_policy);
1295 }
1296
1297 #[test]
1304 fn ts_parity_all_shared_flags_parse() {
1305 let cli = Cli::parse_from([
1307 "pi",
1308 "--provider",
1309 "anthropic",
1310 "--model",
1311 "claude-sonnet-4-5",
1312 "--api-key",
1313 "sk-test",
1314 "--system-prompt",
1315 "You are helpful.",
1316 "--append-system-prompt",
1317 "Extra context.",
1318 "--continue",
1319 "--session",
1320 "/tmp/sess",
1321 "--session-dir",
1322 "/tmp/sessdir",
1323 "--no-session",
1324 "--mode",
1325 "json",
1326 "--print",
1327 "--verbose",
1328 "--no-tools",
1329 "--tools",
1330 "read,bash",
1331 "--thinking",
1332 "high",
1333 "--extension",
1334 "ext.js",
1335 "--no-extensions",
1336 "--skill",
1337 "skill.md",
1338 "--no-skills",
1339 "--prompt-template",
1340 "tmpl.md",
1341 "--no-prompt-templates",
1342 "--theme",
1343 "dark",
1344 "--no-themes",
1345 "--export",
1346 "/tmp/out.html",
1347 "--models",
1348 "claude*,gpt*",
1349 ]);
1350
1351 assert_eq!(cli.provider.as_deref(), Some("anthropic"));
1352 assert_eq!(cli.model.as_deref(), Some("claude-sonnet-4-5"));
1353 assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
1354 assert_eq!(cli.system_prompt.as_deref(), Some("You are helpful."));
1355 assert_eq!(cli.append_system_prompt.as_deref(), Some("Extra context."));
1356 assert!(cli.r#continue);
1357 assert_eq!(cli.session.as_deref(), Some("/tmp/sess"));
1358 assert_eq!(cli.session_dir.as_deref(), Some("/tmp/sessdir"));
1359 assert!(cli.no_session);
1360 assert_eq!(cli.mode.as_deref(), Some("json"));
1361 assert!(cli.print);
1362 assert!(cli.verbose);
1363 assert!(cli.no_tools);
1364 assert_eq!(cli.tools, "read,bash");
1365 assert_eq!(cli.thinking.as_deref(), Some("high"));
1366 assert_eq!(cli.extension, vec!["ext.js"]);
1367 assert!(cli.no_extensions);
1368 assert_eq!(cli.skill, vec!["skill.md"]);
1369 assert!(cli.no_skills);
1370 assert_eq!(cli.prompt_template, vec!["tmpl.md"]);
1371 assert!(cli.no_prompt_templates);
1372 assert_eq!(cli.theme.as_deref(), Some("dark"));
1373 assert!(cli.no_themes);
1374 assert_eq!(cli.export.as_deref(), Some("/tmp/out.html"));
1375 assert_eq!(cli.models.as_deref(), Some("claude*,gpt*"));
1376 }
1377
1378 #[test]
1379 fn ts_parity_short_flags_match() {
1380 let cli = Cli::parse_from(["pi", "-c", "-p", "-e", "ext.js"]);
1383 assert!(cli.r#continue);
1384 assert!(cli.print);
1385 assert_eq!(cli.extension, vec!["ext.js"]);
1386
1387 let cli2 = Cli::parse_from(["pi", "-r"]);
1388 assert!(cli2.resume);
1389 }
1390
1391 #[test]
1392 fn ts_parity_subcommands() {
1393 let cli = Cli::parse_from(["pi", "install", "npm:my-ext"]);
1395 assert!(matches!(cli.command, Some(Commands::Install { .. })));
1396
1397 let cli = Cli::parse_from(["pi", "remove", "npm:my-ext"]);
1398 assert!(matches!(cli.command, Some(Commands::Remove { .. })));
1399
1400 let cli = Cli::parse_from(["pi", "update"]);
1401 assert!(matches!(cli.command, Some(Commands::Update { .. })));
1402
1403 let cli = Cli::parse_from(["pi", "list"]);
1404 assert!(matches!(cli.command, Some(Commands::List)));
1405
1406 let cli = Cli::parse_from(["pi", "config"]);
1407 assert!(matches!(cli.command, Some(Commands::Config { .. })));
1408 }
1409
1410 #[test]
1411 fn ts_parity_at_file_expansion() {
1412 let cli = Cli::parse_from(["pi", "-p", "@readme.md", "summarize this"]);
1413 assert_eq!(cli.file_args(), vec!["readme.md"]);
1414 assert_eq!(cli.message_args(), vec!["summarize this"]);
1415 }
1416
1417 #[test]
1418 fn ts_parity_list_models_optional_search() {
1419 let cli = Cli::parse_from(["pi", "--list-models"]);
1421 assert_eq!(cli.list_models, Some(None));
1422
1423 let cli = Cli::parse_from(["pi", "--list-models", "sonnet"]);
1424 assert_eq!(cli.list_models, Some(Some("sonnet".to_string())));
1425 }
1426
1427 mod proptest_cli {
1430 use crate::cli::{
1431 ExtensionCliFlag, ROOT_SUBCOMMANDS, is_known_short_flag, is_negative_numeric_token,
1432 known_long_option, preprocess_extension_flags, short_flag_expects_value,
1433 };
1434 use proptest::prelude::*;
1435
1436 proptest! {
1437 #[test]
1438 fn is_known_short_flag_accepts_known_char_combos(
1439 combo in prop::sample::select(vec![
1440 "-v", "-c", "-r", "-p", "-e",
1441 "-vc", "-vp", "-cr", "-vcr", "-vcrpe",
1442 ]),
1443 ) {
1444 assert!(
1445 is_known_short_flag(combo),
1446 "'{combo}' should be a known short flag"
1447 );
1448 }
1449
1450 #[test]
1451 fn is_known_short_flag_rejects_unknown_chars(
1452 c in prop::sample::select(vec!['a', 'b', 'd', 'f', 'g', 'h', 'x', 'z']),
1453 ) {
1454 let token = format!("-{c}");
1455 assert!(
1456 !is_known_short_flag(&token),
1457 "'-{c}' should not be a known short flag"
1458 );
1459 }
1460
1461 #[test]
1462 fn is_known_short_flag_rejects_non_dash_prefix(
1463 body in "[a-z]{1,5}",
1464 ) {
1465 assert!(
1466 !is_known_short_flag(&body),
1467 "'{body}' without dash should not be a short flag"
1468 );
1469 }
1470
1471 #[test]
1472 fn is_known_short_flag_rejects_double_dash(
1473 body in "[vcr]{1,5}",
1474 ) {
1475 let token = format!("--{body}");
1476 assert!(
1477 !is_known_short_flag(&token),
1478 "'--{body}' should not be a short flag"
1479 );
1480 }
1481
1482 #[test]
1483 fn short_flag_expects_value_when_cluster_ends_with_e(
1484 prefix in prop::sample::select(vec!["", "p", "c", "vp"]),
1485 ) {
1486 let token = format!("-{prefix}e");
1487 assert!(
1488 short_flag_expects_value(&token),
1489 "'{token}' should expect a following value"
1490 );
1491 }
1492
1493 #[test]
1494 fn short_flag_does_not_expect_value_when_e_has_inline_value(
1495 suffix in prop::sample::select(vec!["v", "c", "r", "p", "vc"]),
1496 ) {
1497 let token = format!("-e{suffix}");
1498 assert!(
1499 !short_flag_expects_value(&token),
1500 "'{token}' should treat '{suffix}' as the inline -e value"
1501 );
1502 }
1503
1504 #[test]
1505 fn is_negative_numeric_token_accepts_negative_integers(
1506 n in 1..10_000i64,
1507 ) {
1508 let token = format!("-{n}");
1509 assert!(
1510 is_negative_numeric_token(&token),
1511 "'{token}' should be a negative numeric token"
1512 );
1513 }
1514
1515 #[test]
1516 fn is_negative_numeric_token_accepts_negative_floats(
1517 whole in 0..100u32,
1518 frac in 1..100u32,
1519 ) {
1520 let token = format!("-{whole}.{frac}");
1521 assert!(
1522 is_negative_numeric_token(&token),
1523 "'{token}' should be a negative numeric token"
1524 );
1525 }
1526
1527 #[test]
1528 fn is_negative_numeric_token_rejects_positive_numbers(
1529 n in 0..10_000u64,
1530 ) {
1531 let token = n.to_string();
1532 assert!(
1533 !is_negative_numeric_token(&token),
1534 "'{token}' (positive) should not be a negative numeric token"
1535 );
1536 }
1537
1538 #[test]
1539 fn is_negative_numeric_token_rejects_non_numeric(
1540 s in "[a-z]{1,5}",
1541 ) {
1542 let token = format!("-{s}");
1543 assert!(
1544 !is_negative_numeric_token(&token),
1545 "'-{s}' should not be a negative numeric token"
1546 );
1547 }
1548
1549 #[test]
1550 fn preprocess_empty_returns_pi_program_name(_dummy in Just(())) {
1551 let result = preprocess_extension_flags(&[]);
1552 assert_eq!(result.0, vec!["pi"]);
1553 let extracted: &[ExtensionCliFlag] = &result.1;
1554 assert!(extracted.is_empty());
1555 }
1556
1557 #[test]
1558 fn preprocess_known_flags_never_extracted(
1559 flag in prop::sample::select(vec![
1560 "--version", "--verbose", "--print", "--no-tools",
1561 "--no-extensions", "--no-skills", "--no-prompt-templates",
1562 ]),
1563 ) {
1564 let args: Vec<String> = vec!["pi".to_string(), flag.to_string()];
1565 let result = preprocess_extension_flags(&args);
1566 let extracted: &[ExtensionCliFlag] = &result.1;
1567 assert!(
1568 extracted.is_empty(),
1569 "known flag '{flag}' should not be extracted"
1570 );
1571 assert!(
1572 result.0.contains(&flag.to_string()),
1573 "known flag '{flag}' should be in filtered"
1574 );
1575 }
1576
1577 #[test]
1578 fn preprocess_unknown_flags_are_extracted(
1579 name in "[a-z]{3,10}".prop_filter(
1580 "must not be a known option",
1581 |n| known_long_option(n).is_none()
1582 && !ROOT_SUBCOMMANDS.contains(&n.as_str()),
1583 ),
1584 ) {
1585 let flag = format!("--{name}");
1586 let args: Vec<String> = vec!["pi".to_string(), flag.clone()];
1587 let result = preprocess_extension_flags(&args);
1588 assert!(
1589 !result.0.contains(&flag),
1590 "unknown flag '{flag}' should not be in filtered"
1591 );
1592 assert_eq!(
1593 result.1.len(), 1,
1594 "should extract exactly one extension flag"
1595 );
1596 assert_eq!(result.1[0].name, name);
1597 }
1598
1599 #[test]
1600 fn preprocess_double_dash_terminates(
1601 tail_count in 0..5usize,
1602 tail_token in "[a-z]{1,5}",
1603 ) {
1604 let mut args = vec!["pi".to_string(), "--".to_string()];
1605 for i in 0..tail_count {
1606 args.push(format!("--{tail_token}{i}"));
1607 }
1608 let result = preprocess_extension_flags(&args);
1609 let extracted: &[ExtensionCliFlag] = &result.1;
1610 assert!(
1611 extracted.is_empty(),
1612 "after --, nothing should be extracted"
1613 );
1614 assert_eq!(result.0.len(), args.len());
1616 }
1617
1618 #[test]
1619 fn preprocess_subcommand_barrier(
1620 subcommand in prop::sample::select(vec![
1621 "install", "remove", "update", "search", "info", "list", "config", "doctor",
1622 "migrate",
1623 ]),
1624 ) {
1625 let args: Vec<String> = vec![
1626 "pi".to_string(),
1627 subcommand.to_string(),
1628 "--unknown-flag".to_string(),
1629 ];
1630 let result = preprocess_extension_flags(&args);
1631 let extracted: &[ExtensionCliFlag] = &result.1;
1632 assert!(
1633 extracted.is_empty(),
1634 "after subcommand '{subcommand}', flags should not be extracted"
1635 );
1636 assert_eq!(result.0.len(), 3);
1637 }
1638
1639 #[test]
1640 fn extension_flag_display_name_format(
1641 name in "[a-z]{1,10}",
1642 ) {
1643 let flag = ExtensionCliFlag {
1644 name: name.clone(),
1645 value: None,
1646 };
1647 assert_eq!(
1648 flag.display_name(),
1649 format!("--{name}"),
1650 "display_name should be --name"
1651 );
1652 }
1653 }
1654 }
1655}
1656
1657#[derive(Subcommand, Debug)]
1659pub enum Commands {
1660 Install {
1662 source: String,
1664 #[arg(short = 'l', long)]
1666 local: bool,
1667 },
1668
1669 Remove {
1671 source: String,
1673 #[arg(short = 'l', long)]
1675 local: bool,
1676 },
1677
1678 Update {
1680 source: Option<String>,
1682 },
1683
1684 #[command(name = "update-index")]
1686 UpdateIndex,
1687
1688 Info {
1690 name: String,
1692 },
1693
1694 Search {
1696 query: String,
1698 #[arg(long)]
1700 tag: Option<String>,
1701 #[arg(long, default_value = "relevance")]
1703 sort: String,
1704 #[arg(long, default_value = "25")]
1706 limit: usize,
1707 },
1708
1709 List,
1711
1712 Config {
1714 #[arg(long)]
1716 show: bool,
1717 #[arg(long)]
1719 paths: bool,
1720 #[arg(long)]
1722 json: bool,
1723 },
1724
1725 Doctor {
1727 path: Option<String>,
1729 #[arg(long, default_value = "text")]
1731 format: String,
1732 #[arg(long)]
1734 policy: Option<String>,
1735 #[arg(long)]
1737 fix: bool,
1738 #[arg(long)]
1740 only: Option<String>,
1741 },
1742
1743 Migrate {
1745 path: String,
1747 #[arg(long)]
1749 dry_run: bool,
1750 },
1751}
1752
1753impl Cli {
1754 pub fn file_args(&self) -> Vec<&str> {
1756 self.args
1757 .iter()
1758 .filter(|a| a.starts_with('@'))
1759 .map(|a| a.strip_prefix('@').unwrap_or(a))
1760 .collect()
1761 }
1762
1763 pub fn message_args(&self) -> Vec<&str> {
1765 self.args
1766 .iter()
1767 .filter(|a| !a.starts_with('@'))
1768 .map(String::as_str)
1769 .collect()
1770 }
1771
1772 pub fn enabled_tools(&self) -> Vec<&str> {
1774 if self.no_tools {
1775 vec![]
1776 } else {
1777 let mut seen = HashSet::new();
1778 self.tools
1779 .split(',')
1780 .map(str::trim)
1781 .filter(|name| !name.is_empty())
1782 .filter(|name| seen.insert(*name))
1783 .collect()
1784 }
1785 }
1786}