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