Skip to main content

pi/
cli.rs

1//! CLI argument parsing using Clap.
2
3use 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)] // Argument normalization needs single-pass stateful parsing.
108fn 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/// Pi - AI coding agent CLI
255#[derive(Parser, Debug)]
256#[allow(clippy::struct_excessive_bools)] // CLI flags are naturally boolean
257#[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    // === Help & Version ===
269    /// Print version information
270    #[arg(short = 'v', long)]
271    pub version: bool,
272
273    // === Model Configuration ===
274    /// LLM provider (e.g., anthropic, openai, google)
275    #[arg(long, env = "PI_PROVIDER")]
276    pub provider: Option<String>,
277
278    /// Model ID (e.g., claude-opus-4, gpt-4o)
279    #[arg(long, env = "PI_MODEL")]
280    pub model: Option<String>,
281
282    /// API key (overrides environment variable)
283    #[arg(long)]
284    pub api_key: Option<String>,
285
286    /// Model patterns for Ctrl+P cycling (comma-separated, supports globs)
287    #[arg(long)]
288    pub models: Option<String>,
289
290    // === Thinking/Reasoning ===
291    /// Extended thinking level
292    #[arg(long, value_parser = ["off", "minimal", "low", "medium", "high", "xhigh"])]
293    pub thinking: Option<String>,
294
295    // === System Prompt ===
296    /// Override system prompt
297    #[arg(long)]
298    pub system_prompt: Option<String>,
299
300    /// Append to system prompt (text or file path)
301    #[arg(long)]
302    pub append_system_prompt: Option<String>,
303
304    // === Session Management ===
305    /// Continue previous session
306    #[arg(short = 'c', long)]
307    pub r#continue: bool,
308
309    /// Select session from picker UI
310    #[arg(short = 'r', long)]
311    pub resume: bool,
312
313    /// Use specific session file path
314    #[arg(long)]
315    pub session: Option<String>,
316
317    /// Directory for session storage/lookup
318    #[arg(long)]
319    pub session_dir: Option<String>,
320
321    /// Don't save session (ephemeral)
322    #[arg(long)]
323    pub no_session: bool,
324
325    /// Session durability mode: strict, balanced, or throughput
326    #[arg(
327        long,
328        value_parser = ["strict", "balanced", "throughput"]
329    )]
330    pub session_durability: Option<String>,
331
332    /// Skip startup migrations for legacy config/session/layout paths
333    #[arg(long)]
334    pub no_migrations: bool,
335
336    // === Mode & Output ===
337    /// Output mode for print mode (text, json, rpc)
338    #[arg(long, value_parser = ["text", "json", "rpc"])]
339    pub mode: Option<String>,
340
341    /// Non-interactive mode (process & exit)
342    #[arg(short = 'p', long)]
343    pub print: bool,
344
345    /// Force verbose startup
346    #[arg(long)]
347    pub verbose: bool,
348
349    // === Tools ===
350    /// Disable all built-in tools
351    #[arg(long)]
352    pub no_tools: bool,
353
354    /// Specific tools to enable (comma-separated: read,bash,edit,write,grep,find,ls)
355    #[arg(long, default_value = "read,bash,edit,write")]
356    pub tools: String,
357
358    // === Extensions ===
359    /// Load extension file (can use multiple times)
360    #[arg(short = 'e', long, action = clap::ArgAction::Append)]
361    pub extension: Vec<String>,
362
363    /// Disable extension discovery
364    #[arg(long)]
365    pub no_extensions: bool,
366
367    /// Extension capability policy: safe, balanced, or permissive (legacy alias: standard)
368    #[arg(long, value_name = "PROFILE")]
369    pub extension_policy: Option<String>,
370
371    /// Print the resolved extension policy with per-capability decisions and exit
372    #[arg(long)]
373    pub explain_extension_policy: bool,
374
375    /// Repair policy mode: off, suggest, auto-safe, or auto-strict
376    #[arg(long, value_name = "MODE")]
377    pub repair_policy: Option<String>,
378
379    /// Print the resolved repair policy and exit
380    #[arg(long)]
381    pub explain_repair_policy: bool,
382
383    // === Skills ===
384    /// Load skill file/directory (can use multiple times)
385    #[arg(long, action = clap::ArgAction::Append)]
386    pub skill: Vec<String>,
387
388    /// Disable skill discovery
389    #[arg(long)]
390    pub no_skills: bool,
391
392    // === Prompt Templates ===
393    /// Load prompt template file/directory (can use multiple times)
394    #[arg(long, action = clap::ArgAction::Append)]
395    pub prompt_template: Vec<String>,
396
397    /// Disable prompt template discovery
398    #[arg(long)]
399    pub no_prompt_templates: bool,
400
401    // === Themes ===
402    /// Select active theme (built-in name, discovered theme name, or theme JSON path)
403    #[arg(long)]
404    pub theme: Option<String>,
405
406    /// Add theme file/directory to discovery (can use multiple times)
407    #[arg(long = "theme-path", action = clap::ArgAction::Append)]
408    pub theme_path: Vec<String>,
409
410    /// Disable theme discovery
411    #[arg(long)]
412    pub no_themes: bool,
413
414    // === Export & Listing ===
415    /// Export session file to HTML
416    #[arg(long)]
417    pub export: Option<String>,
418
419    /// List available models (optional fuzzy search pattern)
420    #[arg(long)]
421    #[allow(clippy::option_option)]
422    // This is intentional: None = not set, Some(None) = set without value, Some(Some(x)) = set with value
423    pub list_models: Option<Option<String>>,
424
425    /// List all supported providers with aliases and auth env keys
426    #[arg(long)]
427    pub list_providers: bool,
428
429    // === Subcommands ===
430    #[command(subcommand)]
431    pub command: Option<Commands>,
432
433    // === Positional Arguments ===
434    /// Messages and @file references
435    #[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    // ── 1. Basic flag parsing ────────────────────────────────────────
445
446    #[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    // ── 2. Thinking level parsing ────────────────────────────────────
604
605    #[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    // ── 3. @file expansion ───────────────────────────────────────────
620
621    #[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    // ── 4. Subcommand parsing ────────────────────────────────────────
656
657    #[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    // ── 5. --list-models (Option<Option<String>>) ────────────────────
814
815    #[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    // ── 5b. --list-providers (bool) ────────────────────────────────────
837
838    #[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    // ── 6. enabled_tools() method ────────────────────────────────────
851
852    #[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    // ── 7. Invalid inputs ────────────────────────────────────────────
877
878    #[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    // ── 8. Multiple append flags ─────────────────────────────────────
1003
1004    #[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    // ── 9. Disable-discovery flags ───────────────────────────────────
1049
1050    #[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    // ── 10. Defaults ─────────────────────────────────────────────────
1069
1070    #[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    // ── 11. Combined flags ───────────────────────────────────────────
1102
1103    #[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    // ── 12. Extension policy flag ───────────────────────────────────
1121
1122    #[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    // ── 13. Repair policy flag ──────────────────────────────────────
1153
1154    #[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    // ── 14. CLI parity: every TS flag is parseable ──────────────────
1179    //
1180    // Reference: legacy_pi_mono_code/.../cli/args.ts
1181    // This test validates that all flags from the TypeScript CLI are
1182    // accepted by the Rust CLI parser (DROPIN-141 / bd-3meug).
1183
1184    #[test]
1185    fn ts_parity_all_shared_flags_parse() {
1186        // Every flag from the TS args.ts that Rust must support.
1187        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        // TS short flags: -c (continue), -r (resume), -p (print),
1262        // -e (extension), -v (version), -h (help)
1263        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        // TS subcommands: install, remove, update, list, config
1275        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        // --list-models with optional search term (TS parity)
1301        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    // ── Property tests ──────────────────────────────────────────────────
1309
1310    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                // All tokens should be in filtered
1474                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/// Package management subcommands
1516#[derive(Subcommand, Debug)]
1517pub enum Commands {
1518    /// Install extension/skill/prompt/theme from source
1519    Install {
1520        /// Package source (npm:pkg, git:url, or local path)
1521        source: String,
1522        /// Install locally (project) instead of globally
1523        #[arg(short = 'l', long)]
1524        local: bool,
1525    },
1526
1527    /// Remove package from settings
1528    Remove {
1529        /// Package source to remove
1530        source: String,
1531        /// Remove from local (project) settings
1532        #[arg(short = 'l', long)]
1533        local: bool,
1534    },
1535
1536    /// Update packages
1537    Update {
1538        /// Specific source to update (or all if omitted)
1539        source: Option<String>,
1540    },
1541
1542    /// Refresh extension index cache from remote sources
1543    #[command(name = "update-index")]
1544    UpdateIndex,
1545
1546    /// Show detailed information about an extension
1547    Info {
1548        /// Extension name or id to look up
1549        name: String,
1550    },
1551
1552    /// Search available extensions by keyword
1553    Search {
1554        /// Search query (e.g. "git", "auto commit")
1555        query: String,
1556        /// Filter results by tag
1557        #[arg(long)]
1558        tag: Option<String>,
1559        /// Sort results: relevance, name
1560        #[arg(long, default_value = "relevance")]
1561        sort: String,
1562        /// Maximum number of results
1563        #[arg(long, default_value = "25")]
1564        limit: usize,
1565    },
1566
1567    /// List installed packages
1568    List,
1569
1570    /// Open configuration UI
1571    Config {
1572        /// Print configuration summary as text (non-interactive)
1573        #[arg(long)]
1574        show: bool,
1575        /// Print path and precedence details only
1576        #[arg(long)]
1577        paths: bool,
1578        /// Print configuration details as JSON
1579        #[arg(long)]
1580        json: bool,
1581    },
1582
1583    /// Diagnose environment health and extension compatibility
1584    Doctor {
1585        /// Extension path to check (omit to run all environment checks)
1586        path: Option<String>,
1587        /// Output format: text (default), json, markdown
1588        #[arg(long, default_value = "text")]
1589        format: String,
1590        /// Extension policy profile to check against
1591        #[arg(long)]
1592        policy: Option<String>,
1593        /// Automatically fix safe issues (missing dirs, permissions)
1594        #[arg(long)]
1595        fix: bool,
1596        /// Run specific categories: config,dirs,auth,shell,sessions,extensions
1597        #[arg(long)]
1598        only: Option<String>,
1599    },
1600
1601    /// Migrate session files from JSONL v1 to v2 segment format
1602    Migrate {
1603        /// Path to specific session JSONL file (or directory to migrate all)
1604        path: String,
1605        /// Dry-run: validate migration without persisting changes
1606        #[arg(long)]
1607        dry_run: bool,
1608    },
1609}
1610
1611impl Cli {
1612    /// Get file arguments (prefixed with @)
1613    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    /// Get message arguments (not prefixed with @)
1622    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    /// Get enabled tools as a list
1631    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}