Skip to main content

pi/
cli.rs

1//! CLI argument parsing using Clap.
2
3use 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)] // Argument normalization needs single-pass stateful parsing.
121fn 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            // We do NOT return early here because `Cli` has trailing varargs for `message`.
235            // If the user provided `pi hello --unknown flag`, clap might happily parse
236            // `--unknown flag` into `message`. We must preprocess extension flags first!
237        }
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/// Pi - AI coding agent CLI
265#[derive(Parser, Debug)]
266#[allow(clippy::struct_excessive_bools)] // CLI flags are naturally boolean
267#[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    // === Help & Version ===
279    /// Print version information
280    #[arg(short = 'v', long)]
281    pub version: bool,
282
283    // === Model Configuration ===
284    /// LLM provider (e.g., anthropic, openai, google).
285    /// Run --list-providers for canonical IDs + aliases.
286    #[arg(long, env = "PI_PROVIDER")]
287    pub provider: Option<String>,
288
289    /// Model ID (e.g., claude-opus-4, gpt-4o)
290    #[arg(long, env = "PI_MODEL")]
291    pub model: Option<String>,
292
293    /// API key (overrides environment variable)
294    #[arg(long)]
295    pub api_key: Option<String>,
296
297    /// Model patterns for Ctrl+P cycling (comma-separated, supports globs)
298    #[arg(long)]
299    pub models: Option<String>,
300
301    // === Thinking/Reasoning ===
302    /// Extended thinking level
303    #[arg(long, value_parser = ["off", "minimal", "low", "medium", "high", "xhigh"])]
304    pub thinking: Option<String>,
305
306    // === System Prompt ===
307    /// Override system prompt
308    #[arg(long)]
309    pub system_prompt: Option<String>,
310
311    /// Append to system prompt (text or file path)
312    #[arg(long)]
313    pub append_system_prompt: Option<String>,
314
315    // === Session Management ===
316    /// Continue previous session
317    #[arg(short = 'c', long)]
318    pub r#continue: bool,
319
320    /// Select session from picker UI
321    #[arg(short = 'r', long)]
322    pub resume: bool,
323
324    /// Use specific session file path
325    #[arg(long)]
326    pub session: Option<String>,
327
328    /// Directory for session storage/lookup
329    #[arg(long)]
330    pub session_dir: Option<String>,
331
332    /// Don't save session (ephemeral)
333    #[arg(long)]
334    pub no_session: bool,
335
336    /// Session durability mode: strict, balanced, or throughput
337    #[arg(
338        long,
339        value_parser = ["strict", "balanced", "throughput"]
340    )]
341    pub session_durability: Option<String>,
342
343    /// Skip startup migrations for legacy config/session/layout paths
344    #[arg(long)]
345    pub no_migrations: bool,
346
347    // === Mode & Output ===
348    /// Output mode for print mode (text, json, rpc)
349    #[arg(long, value_parser = ["text", "json", "rpc"])]
350    pub mode: Option<String>,
351
352    /// Non-interactive mode (process & exit)
353    #[arg(short = 'p', long)]
354    pub print: bool,
355
356    /// Start in ACP (Agent Client Protocol) mode for Zed editor integration.
357    /// Reads JSON-RPC 2.0 requests from stdin and writes responses to stdout.
358    #[arg(long)]
359    pub acp: bool,
360
361    /// Force verbose startup
362    #[arg(long)]
363    pub verbose: bool,
364
365    // === Tools ===
366    /// Disable all built-in tools
367    #[arg(long)]
368    pub no_tools: bool,
369
370    /// Specific tools to enable (comma-separated: read,write,edit,bash,grep,find,ls,hashline_edit)
371    #[arg(
372        long,
373        default_value = "read,bash,edit,write,grep,find,ls,hashline_edit"
374    )]
375    pub tools: String,
376
377    // === Extensions ===
378    /// Load extension file (can use multiple times)
379    #[arg(short = 'e', long, action = clap::ArgAction::Append)]
380    pub extension: Vec<String>,
381
382    /// Disable extension discovery
383    #[arg(long)]
384    pub no_extensions: bool,
385
386    /// Extension capability policy: safe, balanced, or permissive (legacy alias: standard)
387    #[arg(long, value_name = "PROFILE")]
388    pub extension_policy: Option<String>,
389
390    /// Print the resolved extension policy with per-capability decisions and exit
391    #[arg(long)]
392    pub explain_extension_policy: bool,
393
394    /// Repair policy mode: off, suggest, auto-safe, or auto-strict
395    #[arg(long, value_name = "MODE")]
396    pub repair_policy: Option<String>,
397
398    /// Print the resolved repair policy and exit
399    #[arg(long)]
400    pub explain_repair_policy: bool,
401
402    // === Skills ===
403    /// Load skill file/directory (can use multiple times)
404    #[arg(long, action = clap::ArgAction::Append)]
405    pub skill: Vec<String>,
406
407    /// Disable skill discovery
408    #[arg(long)]
409    pub no_skills: bool,
410
411    // === Prompt Templates ===
412    /// Load prompt template file/directory (can use multiple times)
413    #[arg(long, action = clap::ArgAction::Append)]
414    pub prompt_template: Vec<String>,
415
416    /// Disable prompt template discovery
417    #[arg(long)]
418    pub no_prompt_templates: bool,
419
420    // === Themes ===
421    /// Select active theme (built-in name, discovered theme name, or theme JSON path)
422    #[arg(long)]
423    pub theme: Option<String>,
424
425    /// Add theme file/directory to discovery (can use multiple times)
426    #[arg(long = "theme-path", action = clap::ArgAction::Append)]
427    pub theme_path: Vec<String>,
428
429    /// Disable theme discovery
430    #[arg(long)]
431    pub no_themes: bool,
432
433    // === System prompt modifiers ===
434    /// Hide the current working directory from the system prompt.
435    #[arg(long, env = "PI_HIDE_CWD_IN_PROMPT")]
436    pub hide_cwd_in_prompt: bool,
437
438    // === Export & Listing ===
439    /// Export session file to HTML
440    #[arg(long)]
441    pub export: Option<String>,
442
443    /// List available models (optional fuzzy search pattern)
444    #[arg(long)]
445    #[allow(clippy::option_option)]
446    // This is intentional: None = not set, Some(None) = set without value, Some(Some(x)) = set with value
447    pub list_models: Option<Option<String>>,
448
449    /// List all supported providers with aliases and auth env keys
450    #[arg(long)]
451    pub list_providers: bool,
452
453    // === Subcommands ===
454    #[command(subcommand)]
455    pub command: Option<Commands>,
456
457    // === Positional Arguments ===
458    /// Messages and @file references
459    #[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    // ── 1. Basic flag parsing ────────────────────────────────────────
469
470    #[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    // ── 2. Thinking level parsing ────────────────────────────────────
628
629    #[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    // ── 3. @file expansion ───────────────────────────────────────────
644
645    #[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    // ── 4. Subcommand parsing ────────────────────────────────────────
680
681    #[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    // ── 5. --list-models (Option<Option<String>>) ────────────────────
838
839    #[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    // ── 5b. --list-providers (bool) ────────────────────────────────────
861
862    #[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    // ── 6. enabled_tools() method ────────────────────────────────────
875
876    #[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    // ── 7. Invalid inputs ────────────────────────────────────────────
919
920    #[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    // ── 8. Multiple append flags ─────────────────────────────────────
1122
1123    #[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    // ── 9. Disable-discovery flags ───────────────────────────────────
1168
1169    #[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    // ── 10. Defaults ─────────────────────────────────────────────────
1188
1189    #[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    // ── 11. Combined flags ───────────────────────────────────────────
1221
1222    #[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    // ── 12. Extension policy flag ───────────────────────────────────
1240
1241    #[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    // ── 13. Repair policy flag ──────────────────────────────────────
1272
1273    #[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    // ── 14. CLI parity: every TS flag is parseable ──────────────────
1298    //
1299    // Reference: legacy_pi_mono_code/.../cli/args.ts
1300    // This test validates that all flags from the TypeScript CLI are
1301    // accepted by the Rust CLI parser (DROPIN-141 / bd-3meug).
1302
1303    #[test]
1304    fn ts_parity_all_shared_flags_parse() {
1305        // Every flag from the TS args.ts that Rust must support.
1306        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        // TS short flags: -c (continue), -r (resume), -p (print),
1381        // -e (extension), -v (version), -h (help)
1382        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        // TS subcommands: install, remove, update, list, config
1394        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        // --list-models with optional search term (TS parity)
1420        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    // ── Property tests ──────────────────────────────────────────────────
1428
1429    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                // All tokens should be in filtered
1615                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/// Package management subcommands
1658#[derive(Subcommand, Debug)]
1659pub enum Commands {
1660    /// Install extension/skill/prompt/theme from source
1661    Install {
1662        /// Package source (npm:pkg, git:url, or local path)
1663        source: String,
1664        /// Install locally (project) instead of globally
1665        #[arg(short = 'l', long)]
1666        local: bool,
1667    },
1668
1669    /// Remove package from settings
1670    Remove {
1671        /// Package source to remove
1672        source: String,
1673        /// Remove from local (project) settings
1674        #[arg(short = 'l', long)]
1675        local: bool,
1676    },
1677
1678    /// Update packages
1679    Update {
1680        /// Specific source to update (or all if omitted)
1681        source: Option<String>,
1682    },
1683
1684    /// Refresh extension index cache from remote sources
1685    #[command(name = "update-index")]
1686    UpdateIndex,
1687
1688    /// Show detailed information about an extension
1689    Info {
1690        /// Extension name or id to look up
1691        name: String,
1692    },
1693
1694    /// Search available extensions by keyword
1695    Search {
1696        /// Search query (e.g. "git", "auto commit")
1697        query: String,
1698        /// Filter results by tag
1699        #[arg(long)]
1700        tag: Option<String>,
1701        /// Sort results: relevance, name
1702        #[arg(long, default_value = "relevance")]
1703        sort: String,
1704        /// Maximum number of results
1705        #[arg(long, default_value = "25")]
1706        limit: usize,
1707    },
1708
1709    /// List installed packages
1710    List,
1711
1712    /// Open configuration UI
1713    Config {
1714        /// Print configuration summary as text (non-interactive)
1715        #[arg(long)]
1716        show: bool,
1717        /// Print path and precedence details only
1718        #[arg(long)]
1719        paths: bool,
1720        /// Print configuration details as JSON
1721        #[arg(long)]
1722        json: bool,
1723    },
1724
1725    /// Diagnose environment health and extension compatibility
1726    Doctor {
1727        /// Extension path to check (omit to run all environment checks)
1728        path: Option<String>,
1729        /// Output format: text (default), json, markdown
1730        #[arg(long, default_value = "text")]
1731        format: String,
1732        /// Extension policy profile to check against
1733        #[arg(long)]
1734        policy: Option<String>,
1735        /// Automatically fix safe issues (missing dirs, permissions)
1736        #[arg(long)]
1737        fix: bool,
1738        /// Run specific categories: config,dirs,auth,shell,sessions,extensions
1739        #[arg(long)]
1740        only: Option<String>,
1741    },
1742
1743    /// Migrate session files from JSONL v1 to v2 segment format
1744    Migrate {
1745        /// Path to specific session JSONL file (or directory to migrate all)
1746        path: String,
1747        /// Dry-run: validate migration without persisting changes
1748        #[arg(long)]
1749        dry_run: bool,
1750    },
1751}
1752
1753impl Cli {
1754    /// Get file arguments (prefixed with @)
1755    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    /// Get message arguments (not prefixed with @)
1764    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    /// Get enabled tools as a list
1773    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}