use clap::error::ErrorKind;
use clap::{Parser, Subcommand};
use std::collections::HashSet;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtensionCliFlag {
pub name: String,
pub value: Option<String>,
}
impl ExtensionCliFlag {
pub fn display_name(&self) -> String {
format!("--{}", self.name)
}
}
#[derive(Debug)]
pub struct ParsedCli {
pub cli: Cli,
pub extension_flags: Vec<ExtensionCliFlag>,
}
#[derive(Debug, Clone, Copy)]
struct LongOptionSpec {
takes_value: bool,
optional_value: bool,
}
const ROOT_SUBCOMMANDS: &[&str] = &[
"install",
"remove",
"update",
"update-index",
"search",
"info",
"list",
"config",
"doctor",
"migrate",
];
fn known_long_option(name: &str) -> Option<LongOptionSpec> {
let (takes_value, optional_value) = match name {
"version"
| "continue"
| "resume"
| "no-session"
| "no-migrations"
| "print"
| "acp"
| "verbose"
| "no-tools"
| "no-extensions"
| "explain-extension-policy"
| "explain-repair-policy"
| "no-skills"
| "no-prompt-templates"
| "no-themes"
| "list-providers"
| "hide-cwd-in-prompt" => (false, false),
"provider"
| "model"
| "api-key"
| "models"
| "thinking"
| "system-prompt"
| "append-system-prompt"
| "session"
| "session-dir"
| "session-durability"
| "mode"
| "tools"
| "extension"
| "extension-policy"
| "repair-policy"
| "skill"
| "prompt-template"
| "theme"
| "theme-path"
| "export" => (true, false),
"list-models" => (true, true),
_ => return None,
};
Some(LongOptionSpec {
takes_value,
optional_value,
})
}
fn is_known_short_flag(token: &str) -> bool {
if !token.starts_with('-') || token.starts_with("--") {
return false;
}
let body = &token[1..];
if body.is_empty() {
return false;
}
body.chars()
.all(|ch| matches!(ch, 'v' | 'c' | 'r' | 'p' | 'e'))
}
fn short_flag_expects_value(token: &str) -> bool {
if !is_known_short_flag(token) {
return false;
}
let body = &token[1..];
body.find('e').is_some_and(|index| index == body.len() - 1)
}
fn is_negative_numeric_token(token: &str) -> bool {
if !token.starts_with('-') || token == "-" || token.starts_with("--") {
return false;
}
token.parse::<i64>().is_ok() || token.parse::<f64>().is_ok_and(f64::is_finite)
}
#[allow(clippy::too_many_lines)] fn preprocess_extension_flags(raw_args: &[String]) -> (Vec<String>, Vec<ExtensionCliFlag>) {
if raw_args.is_empty() {
return (vec!["pi".to_string()], Vec::new());
}
let mut filtered = Vec::with_capacity(raw_args.len());
filtered.push(raw_args[0].clone());
let mut extracted = Vec::new();
let mut expecting_value = false;
let mut in_subcommand = false;
let mut index = 1usize;
while index < raw_args.len() {
let token = &raw_args[index];
if token == "--" {
filtered.extend(raw_args[index..].iter().cloned());
break;
}
if expecting_value {
filtered.push(token.clone());
expecting_value = false;
index += 1;
continue;
}
if in_subcommand {
filtered.push(token.clone());
index += 1;
continue;
}
if token.starts_with("--") && token.len() > 2 {
let without_prefix = &token[2..];
let (name, has_inline_value) = without_prefix
.split_once('=')
.map_or((without_prefix, false), |(name, _)| (name, true));
if let Some(spec) = known_long_option(name) {
filtered.push(token.clone());
if spec.takes_value && !has_inline_value && !spec.optional_value {
expecting_value = true;
} else if spec.takes_value && !has_inline_value && spec.optional_value {
let has_value = raw_args
.get(index + 1)
.is_some_and(|next| !next.starts_with('-') || next == "-");
expecting_value = has_value;
}
index += 1;
continue;
}
let (name, inline_value) = without_prefix
.split_once('=')
.map_or((without_prefix, None), |(name, value)| {
(name, Some(value.to_string()))
});
if name.is_empty() {
filtered.push(token.clone());
index += 1;
continue;
}
let mut value = inline_value;
if value.is_none() {
let next = raw_args.get(index + 1);
if let Some(next) = next {
if next != "--"
&& (!next.starts_with('-')
|| next == "-"
|| is_negative_numeric_token(next))
{
value = Some(next.clone());
index += 1;
}
}
}
extracted.push(ExtensionCliFlag {
name: name.to_string(),
value,
});
index += 1;
continue;
}
if token == "-e" {
filtered.push(token.clone());
expecting_value = true;
index += 1;
continue;
}
if is_known_short_flag(token) {
filtered.push(token.clone());
expecting_value = short_flag_expects_value(token);
index += 1;
continue;
}
if token.starts_with('-') {
filtered.push(token.clone());
index += 1;
continue;
}
if ROOT_SUBCOMMANDS.contains(&token.as_str()) {
in_subcommand = true;
}
filtered.push(token.clone());
index += 1;
}
(filtered, extracted)
}
pub fn parse_with_extension_flags(raw_args: Vec<String>) -> Result<ParsedCli, clap::Error> {
if raw_args.is_empty() {
let cli = Cli::try_parse_from(["pi"])?;
return Ok(ParsedCli {
cli,
extension_flags: Vec::new(),
});
}
match Cli::try_parse_from(raw_args.clone()) {
Ok(_) => {
}
Err(err) => {
if matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
return Err(err);
}
}
}
let (filtered_args, extension_flags) = preprocess_extension_flags(&raw_args);
if extension_flags.is_empty() {
let cli = Cli::try_parse_from(raw_args)?;
return Ok(ParsedCli {
cli,
extension_flags: Vec::new(),
});
}
let cli = Cli::try_parse_from(filtered_args)?;
Ok(ParsedCli {
cli,
extension_flags,
})
}
#[derive(Parser, Debug)]
#[allow(clippy::struct_excessive_bools)] #[command(name = "pi")]
#[command(version, about, long_about = None, disable_version_flag = true)]
#[command(after_help = "Examples:
pi \"explain this code\" Start new session with message
pi @file.rs \"review this\" Include file in context
pi -c Continue previous session
pi -r Resume from session picker
pi -p \"what is 2+2\" Print mode (non-interactive)
pi --model claude-opus-4 \"help\" Use specific model
")]
pub struct Cli {
#[arg(short = 'v', long)]
pub version: bool,
#[arg(long, env = "PI_PROVIDER")]
pub provider: Option<String>,
#[arg(long, env = "PI_MODEL")]
pub model: Option<String>,
#[arg(long)]
pub api_key: Option<String>,
#[arg(long)]
pub models: Option<String>,
#[arg(long, value_parser = ["off", "minimal", "low", "medium", "high", "xhigh"])]
pub thinking: Option<String>,
#[arg(long)]
pub system_prompt: Option<String>,
#[arg(long)]
pub append_system_prompt: Option<String>,
#[arg(short = 'c', long)]
pub r#continue: bool,
#[arg(short = 'r', long)]
pub resume: bool,
#[arg(long)]
pub session: Option<String>,
#[arg(long)]
pub session_dir: Option<String>,
#[arg(long)]
pub no_session: bool,
#[arg(
long,
value_parser = ["strict", "balanced", "throughput"]
)]
pub session_durability: Option<String>,
#[arg(long)]
pub no_migrations: bool,
#[arg(long, value_parser = ["text", "json", "rpc"])]
pub mode: Option<String>,
#[arg(short = 'p', long)]
pub print: bool,
#[arg(long)]
pub acp: bool,
#[arg(long)]
pub verbose: bool,
#[arg(long)]
pub no_tools: bool,
#[arg(
long,
default_value = "read,bash,edit,write,grep,find,ls,hashline_edit"
)]
pub tools: String,
#[arg(short = 'e', long, action = clap::ArgAction::Append)]
pub extension: Vec<String>,
#[arg(long)]
pub no_extensions: bool,
#[arg(long, value_name = "PROFILE")]
pub extension_policy: Option<String>,
#[arg(long)]
pub explain_extension_policy: bool,
#[arg(long, value_name = "MODE")]
pub repair_policy: Option<String>,
#[arg(long)]
pub explain_repair_policy: bool,
#[arg(long, action = clap::ArgAction::Append)]
pub skill: Vec<String>,
#[arg(long)]
pub no_skills: bool,
#[arg(long, action = clap::ArgAction::Append)]
pub prompt_template: Vec<String>,
#[arg(long)]
pub no_prompt_templates: bool,
#[arg(long)]
pub theme: Option<String>,
#[arg(long = "theme-path", action = clap::ArgAction::Append)]
pub theme_path: Vec<String>,
#[arg(long)]
pub no_themes: bool,
#[arg(long, env = "PI_HIDE_CWD_IN_PROMPT")]
pub hide_cwd_in_prompt: bool,
#[arg(long)]
pub export: Option<String>,
#[arg(long)]
#[allow(clippy::option_option)]
pub list_models: Option<Option<String>>,
#[arg(long)]
pub list_providers: bool,
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(trailing_var_arg = true)]
pub args: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::{Cli, Commands, ROOT_SUBCOMMANDS, parse_with_extension_flags};
use clap::{CommandFactory, Parser};
#[test]
fn parse_resource_flags_and_mode() {
let cli = Cli::parse_from([
"pi",
"--mode",
"rpc",
"--models",
"gpt-4*,claude*",
"--extension",
"ext1",
"--skill",
"skill.md",
"--prompt-template",
"prompt.md",
"--theme",
"dark",
"--theme-path",
"dark.ini",
"--no-themes",
]);
assert_eq!(cli.mode.as_deref(), Some("rpc"));
assert_eq!(cli.models.as_deref(), Some("gpt-4*,claude*"));
assert_eq!(cli.extension, vec!["ext1".to_string()]);
assert_eq!(cli.skill, vec!["skill.md".to_string()]);
assert_eq!(cli.prompt_template, vec!["prompt.md".to_string()]);
assert_eq!(cli.theme.as_deref(), Some("dark"));
assert_eq!(cli.theme_path, vec!["dark.ini".to_string()]);
assert!(cli.no_themes);
}
#[test]
fn parse_continue_short_flag() {
let cli = Cli::parse_from(["pi", "-c"]);
assert!(cli.r#continue);
assert!(!cli.resume);
assert!(!cli.print);
}
#[test]
fn parse_continue_long_flag() {
let cli = Cli::parse_from(["pi", "--continue"]);
assert!(cli.r#continue);
}
#[test]
fn parse_resume_short_flag() {
let cli = Cli::parse_from(["pi", "-r"]);
assert!(cli.resume);
assert!(!cli.r#continue);
}
#[test]
fn parse_session_path() {
let cli = Cli::parse_from(["pi", "--session", "/tmp/session.jsonl"]);
assert_eq!(cli.session.as_deref(), Some("/tmp/session.jsonl"));
}
#[test]
fn parse_session_dir() {
let cli = Cli::parse_from(["pi", "--session-dir", "/tmp/sessions"]);
assert_eq!(cli.session_dir.as_deref(), Some("/tmp/sessions"));
}
#[test]
fn parse_no_session() {
let cli = Cli::parse_from(["pi", "--no-session"]);
assert!(cli.no_session);
}
#[test]
fn parse_session_durability() {
let cli = Cli::parse_from(["pi", "--session-durability", "throughput"]);
assert_eq!(cli.session_durability.as_deref(), Some("throughput"));
}
#[test]
fn parse_no_migrations() {
let cli = Cli::parse_from(["pi", "--no-migrations"]);
assert!(cli.no_migrations);
}
#[test]
fn parse_print_short_flag() {
let cli = Cli::parse_from(["pi", "-p", "what is 2+2"]);
assert!(cli.print);
assert_eq!(cli.message_args(), vec!["what is 2+2"]);
}
#[test]
fn parse_print_long_flag() {
let cli = Cli::parse_from(["pi", "--print", "question"]);
assert!(cli.print);
}
#[test]
fn parse_model_flag() {
let cli = Cli::parse_from(["pi", "--model", "claude-opus-4"]);
assert_eq!(cli.model.as_deref(), Some("claude-opus-4"));
}
#[test]
fn parse_provider_flag() {
let cli = Cli::parse_from(["pi", "--provider", "openai"]);
assert_eq!(cli.provider.as_deref(), Some("openai"));
}
#[test]
fn parse_api_key_flag() {
let cli = Cli::parse_from(["pi", "--api-key", "sk-ant-test123"]);
assert_eq!(cli.api_key.as_deref(), Some("sk-ant-test123"));
}
#[test]
fn parse_version_short_flag() {
let cli = Cli::parse_from(["pi", "-v"]);
assert!(cli.version);
}
#[test]
fn parse_version_long_flag() {
let cli = Cli::parse_from(["pi", "--version"]);
assert!(cli.version);
}
#[test]
fn parse_with_extension_flags_preserves_help_error() {
let err = parse_with_extension_flags(vec!["pi".into(), "--help".into()])
.expect_err("`--help` should stay a clap help path");
assert!(matches!(err.kind(), clap::error::ErrorKind::DisplayHelp));
}
#[test]
fn parse_verbose_flag() {
let cli = Cli::parse_from(["pi", "--verbose"]);
assert!(cli.verbose);
}
#[test]
fn parse_system_prompt_flags() {
let cli = Cli::parse_from([
"pi",
"--system-prompt",
"You are a helper",
"--append-system-prompt",
"Be concise",
]);
assert_eq!(cli.system_prompt.as_deref(), Some("You are a helper"));
assert_eq!(cli.append_system_prompt.as_deref(), Some("Be concise"));
}
#[test]
fn parse_export_flag() {
let cli = Cli::parse_from(["pi", "--export", "output.html"]);
assert_eq!(cli.export.as_deref(), Some("output.html"));
}
#[test]
fn parse_all_thinking_levels() {
for level in &["off", "minimal", "low", "medium", "high", "xhigh"] {
let cli = Cli::parse_from(["pi", "--thinking", level]);
assert_eq!(cli.thinking.as_deref(), Some(*level));
}
}
#[test]
fn invalid_thinking_level_rejected() {
let result = Cli::try_parse_from(["pi", "--thinking", "ultra"]);
assert!(result.is_err());
}
#[test]
fn file_and_message_args_split() {
let cli = Cli::parse_from(["pi", "@a.txt", "hello", "@b.md", "world"]);
assert_eq!(cli.file_args(), vec!["a.txt", "b.md"]);
assert_eq!(cli.message_args(), vec!["hello", "world"]);
}
#[test]
fn file_args_empty_when_none() {
let cli = Cli::parse_from(["pi", "hello", "world"]);
assert!(cli.file_args().is_empty());
assert_eq!(cli.message_args(), vec!["hello", "world"]);
}
#[test]
fn message_args_empty_when_only_files() {
let cli = Cli::parse_from(["pi", "@src/main.rs", "@Cargo.toml"]);
assert_eq!(cli.file_args(), vec!["src/main.rs", "Cargo.toml"]);
assert!(cli.message_args().is_empty());
}
#[test]
fn no_positional_args_yields_empty() {
let cli = Cli::parse_from(["pi"]);
assert!(cli.file_args().is_empty());
assert!(cli.message_args().is_empty());
}
#[test]
fn at_prefix_stripped_from_file_paths() {
let cli = Cli::parse_from(["pi", "@/absolute/path.rs"]);
assert_eq!(cli.file_args(), vec!["/absolute/path.rs"]);
}
#[test]
fn parse_install_subcommand() {
let cli = Cli::parse_from(["pi", "install", "npm:@org/pkg"]);
match cli.command {
Some(Commands::Install { source, local }) => {
assert_eq!(source, "npm:@org/pkg");
assert!(!local);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_install_local_flag() {
let cli = Cli::parse_from(["pi", "install", "--local", "git:https://example.com"]);
match cli.command {
Some(Commands::Install { source, local }) => {
assert_eq!(source, "git:https://example.com");
assert!(local);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_install_local_short_flag() {
let cli = Cli::parse_from(["pi", "install", "-l", "./local-ext"]);
match cli.command {
Some(Commands::Install { local, .. }) => assert!(local),
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_remove_subcommand() {
let cli = Cli::parse_from(["pi", "remove", "npm:pkg"]);
match cli.command {
Some(Commands::Remove { source, local }) => {
assert_eq!(source, "npm:pkg");
assert!(!local);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_remove_local_flag() {
let cli = Cli::parse_from(["pi", "remove", "--local", "npm:pkg"]);
match cli.command {
Some(Commands::Remove { local, .. }) => assert!(local),
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_update_with_source() {
let cli = Cli::parse_from(["pi", "update", "npm:pkg"]);
match cli.command {
Some(Commands::Update { source }) => {
assert_eq!(source.as_deref(), Some("npm:pkg"));
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_update_all() {
let cli = Cli::parse_from(["pi", "update"]);
match cli.command {
Some(Commands::Update { source }) => assert!(source.is_none()),
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_list_subcommand() {
let cli = Cli::parse_from(["pi", "list"]);
assert!(matches!(cli.command, Some(Commands::List)));
}
#[test]
fn parse_config_subcommand() {
let cli = Cli::parse_from(["pi", "config"]);
match cli.command {
Some(Commands::Config { show, paths, json }) => {
assert!(!show);
assert!(!paths);
assert!(!json);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_config_show_flag() {
let cli = Cli::parse_from(["pi", "config", "--show"]);
match cli.command {
Some(Commands::Config { show, paths, json }) => {
assert!(show);
assert!(!paths);
assert!(!json);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_config_paths_flag() {
let cli = Cli::parse_from(["pi", "config", "--paths"]);
match cli.command {
Some(Commands::Config { show, paths, json }) => {
assert!(!show);
assert!(paths);
assert!(!json);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_config_json_flag() {
let cli = Cli::parse_from(["pi", "config", "--json"]);
match cli.command {
Some(Commands::Config { show, paths, json }) => {
assert!(!show);
assert!(!paths);
assert!(json);
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn parse_update_index_subcommand() {
let cli = Cli::parse_from(["pi", "update-index"]);
assert!(matches!(cli.command, Some(Commands::UpdateIndex)));
}
#[test]
fn parse_info_subcommand() {
let cli = Cli::parse_from(["pi", "info", "auto-commit-on-exit"]);
match cli.command {
Some(Commands::Info { name }) => {
assert_eq!(name, "auto-commit-on-exit");
}
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn no_subcommand_when_only_message() {
let cli = Cli::parse_from(["pi", "hello"]);
assert!(cli.command.is_none());
assert_eq!(cli.message_args(), vec!["hello"]);
}
#[test]
fn list_models_not_set() {
let cli = Cli::parse_from(["pi"]);
assert!(cli.list_models.is_none());
}
#[test]
fn list_models_without_pattern() {
let cli = Cli::parse_from(["pi", "--list-models"]);
assert!(matches!(cli.list_models, Some(None)));
}
#[test]
fn list_models_with_pattern() {
let cli = Cli::parse_from(["pi", "--list-models", "claude*"]);
match cli.list_models {
Some(Some(ref pat)) => assert_eq!(pat, "claude*"),
other => panic!("unexpected command: {:?}", other),
}
}
#[test]
fn list_providers_not_set() {
let cli = Cli::parse_from(["pi"]);
assert!(!cli.list_providers);
}
#[test]
fn list_providers_set() {
let cli = Cli::parse_from(["pi", "--list-providers"]);
assert!(cli.list_providers);
}
#[test]
fn default_tools() {
let cli = Cli::parse_from(["pi"]);
assert_eq!(
cli.enabled_tools(),
vec![
"read",
"bash",
"edit",
"write",
"grep",
"find",
"ls",
"hashline_edit",
]
);
}
#[test]
fn custom_tools_list() {
let cli = Cli::parse_from(["pi", "--tools", "read,grep,find,ls"]);
assert_eq!(cli.enabled_tools(), vec!["read", "grep", "find", "ls"]);
}
#[test]
fn no_tools_flag_returns_empty() {
let cli = Cli::parse_from(["pi", "--no-tools"]);
assert!(cli.enabled_tools().is_empty());
}
#[test]
fn tools_with_spaces_trimmed() {
let cli = Cli::parse_from(["pi", "--tools", "read, bash, edit"]);
assert_eq!(cli.enabled_tools(), vec!["read", "bash", "edit"]);
}
#[test]
fn tools_ignore_empty_entries_and_duplicates() {
let cli = Cli::parse_from(["pi", "--tools", "read,, bash,read, ,grep,bash"]);
assert_eq!(cli.enabled_tools(), vec!["read", "bash", "grep"]);
}
#[test]
fn unknown_flag_rejected() {
let result = Cli::try_parse_from(["pi", "--nonexistent"]);
assert!(result.is_err());
}
#[test]
fn invalid_mode_rejected() {
let result = Cli::try_parse_from(["pi", "--mode", "xml"]);
assert!(result.is_err());
}
#[test]
fn install_without_source_rejected() {
let result = Cli::try_parse_from(["pi", "install"]);
assert!(result.is_err());
}
#[test]
fn remove_without_source_rejected() {
let result = Cli::try_parse_from(["pi", "remove"]);
assert!(result.is_err());
}
#[test]
fn invalid_subcommand_option_rejected() {
let result = Cli::try_parse_from(["pi", "install", "--bogus", "npm:pkg"]);
assert!(result.is_err());
}
#[test]
fn extension_flags_are_extracted_in_second_pass_parse() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"--plan".to_string(),
"ship it".to_string(),
"--model".to_string(),
"gpt-4o".to_string(),
])
.expect("parse with extension flags");
assert_eq!(parsed.cli.model.as_deref(), Some("gpt-4o"));
assert_eq!(parsed.extension_flags.len(), 1);
assert_eq!(parsed.extension_flags[0].name, "plan");
assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("ship it"));
}
#[test]
fn extension_bool_flag_without_value_is_supported() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"--dry-run".to_string(),
"--print".to_string(),
"hello".to_string(),
])
.expect("parse extension bool flag");
assert!(parsed.cli.print);
assert_eq!(parsed.extension_flags.len(), 1);
assert_eq!(parsed.extension_flags[0].name, "dry-run");
assert!(parsed.extension_flags[0].value.is_none());
}
#[test]
fn extension_flag_accepts_negative_integer_value() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"--temperature".to_string(),
"-1".to_string(),
"--print".to_string(),
"hello".to_string(),
])
.expect("parse negative integer value");
assert!(parsed.cli.print);
assert_eq!(parsed.extension_flags.len(), 1);
assert_eq!(parsed.extension_flags[0].name, "temperature");
assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("-1"));
}
#[test]
fn extension_flag_accepts_negative_float_value() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"--temperature".to_string(),
"-0.25".to_string(),
"--print".to_string(),
"hello".to_string(),
])
.expect("parse negative float value");
assert!(parsed.cli.print);
assert_eq!(parsed.extension_flags.len(), 1);
assert_eq!(parsed.extension_flags[0].name, "temperature");
assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("-0.25"));
}
#[test]
fn parse_with_extension_flags_recognizes_session_durability_as_builtin() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"--session-durability".to_string(),
"throughput".to_string(),
"--print".to_string(),
"hello".to_string(),
])
.expect("parse with session durability");
assert_eq!(parsed.cli.session_durability.as_deref(), Some("throughput"));
assert!(parsed.extension_flags.is_empty());
assert!(parsed.cli.print);
}
#[test]
fn extension_flag_parser_does_not_bypass_subcommand_validation() {
let result = parse_with_extension_flags(vec![
"pi".to_string(),
"install".to_string(),
"--bogus".to_string(),
"pkg".to_string(),
]);
assert!(result.is_err());
}
#[test]
fn extension_flags_survive_short_cluster_ending_in_e() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"-pe".to_string(),
"ext.js".to_string(),
"--plan".to_string(),
"ship-it".to_string(),
"hello".to_string(),
])
.expect("parse short cluster with extension");
assert!(parsed.cli.print);
assert_eq!(parsed.cli.extension, vec!["ext.js".to_string()]);
assert_eq!(parsed.cli.message_args(), vec!["hello"]);
assert_eq!(parsed.extension_flags.len(), 1);
assert_eq!(parsed.extension_flags[0].name, "plan");
assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("ship-it"));
}
#[test]
fn extension_flags_after_message_args_are_extracted() {
let parsed = parse_with_extension_flags(vec![
"pi".to_string(),
"hello".to_string(),
"--plan".to_string(),
"ship-it".to_string(),
])
.expect("parse extension flag after message");
assert_eq!(parsed.cli.message_args(), vec!["hello"]);
assert_eq!(parsed.extension_flags.len(), 1);
assert_eq!(parsed.extension_flags[0].name, "plan");
assert_eq!(parsed.extension_flags[0].value.as_deref(), Some("ship-it"));
}
#[test]
fn extension_flag_inline_value_matches_separate_value() {
let separate = parse_with_extension_flags(vec![
"pi".to_string(),
"--plan".to_string(),
"ship-it".to_string(),
"--print".to_string(),
"hello".to_string(),
])
.expect("parse separate extension flag");
let inline = parse_with_extension_flags(vec![
"pi".to_string(),
"--plan=ship-it".to_string(),
"--print".to_string(),
"hello".to_string(),
])
.expect("parse inline extension flag");
assert_eq!(separate.cli.print, inline.cli.print);
assert_eq!(separate.cli.message_args(), inline.cli.message_args());
assert_eq!(separate.extension_flags, inline.extension_flags);
}
#[test]
fn root_subcommands_constant_matches_clap_parser() {
let mut actual = Cli::command()
.get_subcommands()
.map(|command| command.get_name().to_string())
.collect::<Vec<_>>();
actual.sort();
let mut expected = ROOT_SUBCOMMANDS
.iter()
.map(|name| (*name).to_string())
.collect::<Vec<_>>();
expected.sort();
assert_eq!(expected, actual);
}
#[test]
fn multiple_extensions() {
let cli = Cli::parse_from([
"pi",
"--extension",
"ext1.js",
"-e",
"ext2.js",
"--extension",
"ext3.js",
]);
assert_eq!(
cli.extension,
vec!["ext1.js", "ext2.js", "ext3.js"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn multiple_skills() {
let cli = Cli::parse_from(["pi", "--skill", "a.md", "--skill", "b.md"]);
assert_eq!(
cli.skill,
vec!["a.md", "b.md"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn multiple_theme_paths() {
let cli = Cli::parse_from(["pi", "--theme-path", "a/", "--theme-path", "b/"]);
assert_eq!(
cli.theme_path,
vec!["a/", "b/"]
.into_iter()
.map(String::from)
.collect::<Vec<_>>()
);
}
#[test]
fn no_extensions_flag() {
let cli = Cli::parse_from(["pi", "--no-extensions"]);
assert!(cli.no_extensions);
}
#[test]
fn no_skills_flag() {
let cli = Cli::parse_from(["pi", "--no-skills"]);
assert!(cli.no_skills);
}
#[test]
fn no_prompt_templates_flag() {
let cli = Cli::parse_from(["pi", "--no-prompt-templates"]);
assert!(cli.no_prompt_templates);
}
#[test]
fn bare_invocation_defaults() {
let cli = Cli::parse_from(["pi"]);
assert!(!cli.version);
assert!(!cli.r#continue);
assert!(!cli.resume);
assert!(!cli.print);
assert!(!cli.verbose);
assert!(!cli.no_session);
assert!(!cli.no_migrations);
assert!(!cli.no_tools);
assert!(!cli.no_extensions);
assert!(!cli.no_skills);
assert!(!cli.no_prompt_templates);
assert!(!cli.no_themes);
assert!(cli.provider.is_none());
assert!(cli.model.is_none());
assert!(cli.api_key.is_none());
assert!(cli.thinking.is_none());
assert!(cli.session.is_none());
assert!(cli.session_dir.is_none());
assert!(cli.mode.is_none());
assert!(cli.export.is_none());
assert!(cli.system_prompt.is_none());
assert!(cli.append_system_prompt.is_none());
assert!(cli.list_models.is_none());
assert!(cli.command.is_none());
assert!(cli.args.is_empty());
assert_eq!(cli.tools, "read,bash,edit,write,grep,find,ls,hashline_edit");
}
#[test]
fn print_mode_with_model_and_thinking() {
let cli = Cli::parse_from([
"pi",
"-p",
"--model",
"gpt-4o",
"--thinking",
"high",
"solve this problem",
]);
assert!(cli.print);
assert_eq!(cli.model.as_deref(), Some("gpt-4o"));
assert_eq!(cli.thinking.as_deref(), Some("high"));
assert_eq!(cli.message_args(), vec!["solve this problem"]);
}
#[test]
fn extension_policy_flag_parses() {
let cli = Cli::parse_from(["pi", "--extension-policy", "safe"]);
assert_eq!(cli.extension_policy.as_deref(), Some("safe"));
}
#[test]
fn extension_policy_flag_permissive() {
let cli = Cli::parse_from(["pi", "--extension-policy", "permissive"]);
assert_eq!(cli.extension_policy.as_deref(), Some("permissive"));
}
#[test]
fn extension_policy_flag_balanced() {
let cli = Cli::parse_from(["pi", "--extension-policy", "balanced"]);
assert_eq!(cli.extension_policy.as_deref(), Some("balanced"));
}
#[test]
fn extension_policy_flag_absent() {
let cli = Cli::parse_from(["pi"]);
assert!(cli.extension_policy.is_none());
}
#[test]
fn explain_extension_policy_flag_parses() {
let cli = Cli::parse_from(["pi", "--explain-extension-policy"]);
assert!(cli.explain_extension_policy);
}
#[test]
fn repair_policy_flag_parses() {
let cli = Cli::parse_from(["pi", "--repair-policy", "auto-safe"]);
assert_eq!(cli.repair_policy.as_deref(), Some("auto-safe"));
}
#[test]
fn repair_policy_flag_off() {
let cli = Cli::parse_from(["pi", "--repair-policy", "off"]);
assert_eq!(cli.repair_policy.as_deref(), Some("off"));
}
#[test]
fn repair_policy_flag_absent() {
let cli = Cli::parse_from(["pi"]);
assert!(cli.repair_policy.is_none());
}
#[test]
fn explain_repair_policy_flag_parses() {
let cli = Cli::parse_from(["pi", "--explain-repair-policy"]);
assert!(cli.explain_repair_policy);
}
#[test]
fn ts_parity_all_shared_flags_parse() {
let cli = Cli::parse_from([
"pi",
"--provider",
"anthropic",
"--model",
"claude-sonnet-4-5",
"--api-key",
"sk-test",
"--system-prompt",
"You are helpful.",
"--append-system-prompt",
"Extra context.",
"--continue",
"--session",
"/tmp/sess",
"--session-dir",
"/tmp/sessdir",
"--no-session",
"--mode",
"json",
"--print",
"--verbose",
"--no-tools",
"--tools",
"read,bash",
"--thinking",
"high",
"--extension",
"ext.js",
"--no-extensions",
"--skill",
"skill.md",
"--no-skills",
"--prompt-template",
"tmpl.md",
"--no-prompt-templates",
"--theme",
"dark",
"--no-themes",
"--export",
"/tmp/out.html",
"--models",
"claude*,gpt*",
]);
assert_eq!(cli.provider.as_deref(), Some("anthropic"));
assert_eq!(cli.model.as_deref(), Some("claude-sonnet-4-5"));
assert_eq!(cli.api_key.as_deref(), Some("sk-test"));
assert_eq!(cli.system_prompt.as_deref(), Some("You are helpful."));
assert_eq!(cli.append_system_prompt.as_deref(), Some("Extra context."));
assert!(cli.r#continue);
assert_eq!(cli.session.as_deref(), Some("/tmp/sess"));
assert_eq!(cli.session_dir.as_deref(), Some("/tmp/sessdir"));
assert!(cli.no_session);
assert_eq!(cli.mode.as_deref(), Some("json"));
assert!(cli.print);
assert!(cli.verbose);
assert!(cli.no_tools);
assert_eq!(cli.tools, "read,bash");
assert_eq!(cli.thinking.as_deref(), Some("high"));
assert_eq!(cli.extension, vec!["ext.js"]);
assert!(cli.no_extensions);
assert_eq!(cli.skill, vec!["skill.md"]);
assert!(cli.no_skills);
assert_eq!(cli.prompt_template, vec!["tmpl.md"]);
assert!(cli.no_prompt_templates);
assert_eq!(cli.theme.as_deref(), Some("dark"));
assert!(cli.no_themes);
assert_eq!(cli.export.as_deref(), Some("/tmp/out.html"));
assert_eq!(cli.models.as_deref(), Some("claude*,gpt*"));
}
#[test]
fn ts_parity_short_flags_match() {
let cli = Cli::parse_from(["pi", "-c", "-p", "-e", "ext.js"]);
assert!(cli.r#continue);
assert!(cli.print);
assert_eq!(cli.extension, vec!["ext.js"]);
let cli2 = Cli::parse_from(["pi", "-r"]);
assert!(cli2.resume);
}
#[test]
fn ts_parity_subcommands() {
let cli = Cli::parse_from(["pi", "install", "npm:my-ext"]);
assert!(matches!(cli.command, Some(Commands::Install { .. })));
let cli = Cli::parse_from(["pi", "remove", "npm:my-ext"]);
assert!(matches!(cli.command, Some(Commands::Remove { .. })));
let cli = Cli::parse_from(["pi", "update"]);
assert!(matches!(cli.command, Some(Commands::Update { .. })));
let cli = Cli::parse_from(["pi", "list"]);
assert!(matches!(cli.command, Some(Commands::List)));
let cli = Cli::parse_from(["pi", "config"]);
assert!(matches!(cli.command, Some(Commands::Config { .. })));
}
#[test]
fn ts_parity_at_file_expansion() {
let cli = Cli::parse_from(["pi", "-p", "@readme.md", "summarize this"]);
assert_eq!(cli.file_args(), vec!["readme.md"]);
assert_eq!(cli.message_args(), vec!["summarize this"]);
}
#[test]
fn ts_parity_list_models_optional_search() {
let cli = Cli::parse_from(["pi", "--list-models"]);
assert_eq!(cli.list_models, Some(None));
let cli = Cli::parse_from(["pi", "--list-models", "sonnet"]);
assert_eq!(cli.list_models, Some(Some("sonnet".to_string())));
}
mod proptest_cli {
use crate::cli::{
ExtensionCliFlag, ROOT_SUBCOMMANDS, is_known_short_flag, is_negative_numeric_token,
known_long_option, preprocess_extension_flags, short_flag_expects_value,
};
use proptest::prelude::*;
proptest! {
#[test]
fn is_known_short_flag_accepts_known_char_combos(
combo in prop::sample::select(vec![
"-v", "-c", "-r", "-p", "-e",
"-vc", "-vp", "-cr", "-vcr", "-vcrpe",
]),
) {
assert!(
is_known_short_flag(combo),
"'{combo}' should be a known short flag"
);
}
#[test]
fn is_known_short_flag_rejects_unknown_chars(
c in prop::sample::select(vec!['a', 'b', 'd', 'f', 'g', 'h', 'x', 'z']),
) {
let token = format!("-{c}");
assert!(
!is_known_short_flag(&token),
"'-{c}' should not be a known short flag"
);
}
#[test]
fn is_known_short_flag_rejects_non_dash_prefix(
body in "[a-z]{1,5}",
) {
assert!(
!is_known_short_flag(&body),
"'{body}' without dash should not be a short flag"
);
}
#[test]
fn is_known_short_flag_rejects_double_dash(
body in "[vcr]{1,5}",
) {
let token = format!("--{body}");
assert!(
!is_known_short_flag(&token),
"'--{body}' should not be a short flag"
);
}
#[test]
fn short_flag_expects_value_when_cluster_ends_with_e(
prefix in prop::sample::select(vec!["", "p", "c", "vp"]),
) {
let token = format!("-{prefix}e");
assert!(
short_flag_expects_value(&token),
"'{token}' should expect a following value"
);
}
#[test]
fn short_flag_does_not_expect_value_when_e_has_inline_value(
suffix in prop::sample::select(vec!["v", "c", "r", "p", "vc"]),
) {
let token = format!("-e{suffix}");
assert!(
!short_flag_expects_value(&token),
"'{token}' should treat '{suffix}' as the inline -e value"
);
}
#[test]
fn is_negative_numeric_token_accepts_negative_integers(
n in 1..10_000i64,
) {
let token = format!("-{n}");
assert!(
is_negative_numeric_token(&token),
"'{token}' should be a negative numeric token"
);
}
#[test]
fn is_negative_numeric_token_accepts_negative_floats(
whole in 0..100u32,
frac in 1..100u32,
) {
let token = format!("-{whole}.{frac}");
assert!(
is_negative_numeric_token(&token),
"'{token}' should be a negative numeric token"
);
}
#[test]
fn is_negative_numeric_token_rejects_positive_numbers(
n in 0..10_000u64,
) {
let token = n.to_string();
assert!(
!is_negative_numeric_token(&token),
"'{token}' (positive) should not be a negative numeric token"
);
}
#[test]
fn is_negative_numeric_token_rejects_non_numeric(
s in "[a-z]{1,5}",
) {
let token = format!("-{s}");
assert!(
!is_negative_numeric_token(&token),
"'-{s}' should not be a negative numeric token"
);
}
#[test]
fn preprocess_empty_returns_pi_program_name(_dummy in Just(())) {
let result = preprocess_extension_flags(&[]);
assert_eq!(result.0, vec!["pi"]);
let extracted: &[ExtensionCliFlag] = &result.1;
assert!(extracted.is_empty());
}
#[test]
fn preprocess_known_flags_never_extracted(
flag in prop::sample::select(vec![
"--version", "--verbose", "--print", "--no-tools",
"--no-extensions", "--no-skills", "--no-prompt-templates",
]),
) {
let args: Vec<String> = vec!["pi".to_string(), flag.to_string()];
let result = preprocess_extension_flags(&args);
let extracted: &[ExtensionCliFlag] = &result.1;
assert!(
extracted.is_empty(),
"known flag '{flag}' should not be extracted"
);
assert!(
result.0.contains(&flag.to_string()),
"known flag '{flag}' should be in filtered"
);
}
#[test]
fn preprocess_unknown_flags_are_extracted(
name in "[a-z]{3,10}".prop_filter(
"must not be a known option",
|n| known_long_option(n).is_none()
&& !ROOT_SUBCOMMANDS.contains(&n.as_str()),
),
) {
let flag = format!("--{name}");
let args: Vec<String> = vec!["pi".to_string(), flag.clone()];
let result = preprocess_extension_flags(&args);
assert!(
!result.0.contains(&flag),
"unknown flag '{flag}' should not be in filtered"
);
assert_eq!(
result.1.len(), 1,
"should extract exactly one extension flag"
);
assert_eq!(result.1[0].name, name);
}
#[test]
fn preprocess_double_dash_terminates(
tail_count in 0..5usize,
tail_token in "[a-z]{1,5}",
) {
let mut args = vec!["pi".to_string(), "--".to_string()];
for i in 0..tail_count {
args.push(format!("--{tail_token}{i}"));
}
let result = preprocess_extension_flags(&args);
let extracted: &[ExtensionCliFlag] = &result.1;
assert!(
extracted.is_empty(),
"after --, nothing should be extracted"
);
assert_eq!(result.0.len(), args.len());
}
#[test]
fn preprocess_subcommand_barrier(
subcommand in prop::sample::select(vec![
"install", "remove", "update", "search", "info", "list", "config", "doctor",
"migrate",
]),
) {
let args: Vec<String> = vec![
"pi".to_string(),
subcommand.to_string(),
"--unknown-flag".to_string(),
];
let result = preprocess_extension_flags(&args);
let extracted: &[ExtensionCliFlag] = &result.1;
assert!(
extracted.is_empty(),
"after subcommand '{subcommand}', flags should not be extracted"
);
assert_eq!(result.0.len(), 3);
}
#[test]
fn extension_flag_display_name_format(
name in "[a-z]{1,10}",
) {
let flag = ExtensionCliFlag {
name: name.clone(),
value: None,
};
assert_eq!(
flag.display_name(),
format!("--{name}"),
"display_name should be --name"
);
}
}
}
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Install {
source: String,
#[arg(short = 'l', long)]
local: bool,
},
Remove {
source: String,
#[arg(short = 'l', long)]
local: bool,
},
Update {
source: Option<String>,
},
#[command(name = "update-index")]
UpdateIndex,
Info {
name: String,
},
Search {
query: String,
#[arg(long)]
tag: Option<String>,
#[arg(long, default_value = "relevance")]
sort: String,
#[arg(long, default_value = "25")]
limit: usize,
},
List,
Config {
#[arg(long)]
show: bool,
#[arg(long)]
paths: bool,
#[arg(long)]
json: bool,
},
Doctor {
path: Option<String>,
#[arg(long, default_value = "text")]
format: String,
#[arg(long)]
policy: Option<String>,
#[arg(long)]
fix: bool,
#[arg(long)]
only: Option<String>,
},
Migrate {
path: String,
#[arg(long)]
dry_run: bool,
},
}
impl Cli {
pub fn file_args(&self) -> Vec<&str> {
self.args
.iter()
.filter(|a| a.starts_with('@'))
.map(|a| a.strip_prefix('@').unwrap_or(a))
.collect()
}
pub fn message_args(&self) -> Vec<&str> {
self.args
.iter()
.filter(|a| !a.starts_with('@'))
.map(String::as_str)
.collect()
}
pub fn enabled_tools(&self) -> Vec<&str> {
if self.no_tools {
vec![]
} else {
let mut seen = HashSet::new();
self.tools
.split(',')
.map(str::trim)
.filter(|name| !name.is_empty())
.filter(|name| seen.insert(*name))
.collect()
}
}
}