use std::collections::BTreeMap;
use std::path::PathBuf;
use rpi_ai::ThinkingLevel;
pub(crate) const PI_OFFLINE_ENV: &str = "PI_OFFLINE";
pub(crate) fn is_truthy_env_flag(value: Option<&str>) -> bool {
value.is_some_and(|value| {
value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
})
}
pub(crate) fn offline_env_enabled() -> bool {
is_truthy_env_flag(std::env::var(PI_OFFLINE_ENV).ok().as_deref())
}
pub(crate) fn offline_mode_enabled(cli_offline: bool) -> bool {
cli_offline || offline_env_enabled()
}
pub(crate) fn normalize_offline_mode(args: &[String]) -> bool {
let enabled = offline_mode_enabled(args.iter().any(|arg| arg == "--offline"));
if enabled {
std::env::set_var(PI_OFFLINE_ENV, "1");
}
enabled
}
pub(crate) fn without_offline_flag(args: &[String]) -> Vec<String> {
args.iter()
.filter(|arg| arg.as_str() != "--offline")
.cloned()
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Mode {
#[default]
Text,
Json,
Rpc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TuiMode {
#[default]
Fullscreen,
Regular,
}
#[derive(Debug, Clone, Default)]
pub struct Args {
pub provider: Option<String>,
pub model: Option<String>,
pub api_key: Option<String>,
pub base_url: Option<String>,
pub system_prompt: Option<String>,
pub append_system_prompt: Vec<String>,
pub theme: Option<String>,
pub thinking: Option<ThinkingLevel>,
pub print: bool,
pub mode: Mode,
pub tui_mode: TuiMode,
pub list_models: Option<String>,
pub offline: bool,
pub export: Option<PathBuf>,
pub trust_override: Option<bool>,
pub continue_session: bool,
pub resume: bool,
pub session: Option<String>,
pub session_id: Option<String>,
pub fork: Option<String>,
pub models: Option<Vec<String>>,
pub session_dir: Option<PathBuf>,
pub no_session: bool,
pub name: Option<String>,
pub tools: Option<Vec<String>>,
pub exclude_tools: Option<Vec<String>>,
pub no_tools: bool,
pub no_builtin_tools: bool,
pub no_skills: bool,
pub no_prompt_templates: bool,
pub no_context_files: bool,
pub no_extensions: bool,
pub enable_pi_packages: bool,
pub no_themes: bool,
pub extensions_dir: Vec<PathBuf>,
pub extension: Vec<PathBuf>,
pub skill: Vec<PathBuf>,
pub prompt_template: Vec<PathBuf>,
pub dev_local_only: bool,
pub verbose: bool,
pub help: bool,
pub version: bool,
pub debug_system_prompt: bool,
pub messages: Vec<String>,
pub file_args: Vec<PathBuf>,
pub unknown_flags: BTreeMap<String, serde_json::Value>,
pub ignored: Vec<String>,
pub errors: Vec<String>,
}
pub const VALID_THINKING_LEVELS: &[&str] =
&["off", "minimal", "low", "medium", "high", "xhigh", "max"];
pub fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
Some(match s {
"off" => ThinkingLevel::Off,
"minimal" => ThinkingLevel::Minimal,
"low" => ThinkingLevel::Low,
"medium" => ThinkingLevel::Medium,
"high" => ThinkingLevel::High,
"xhigh" => ThinkingLevel::Xhigh,
"max" => ThinkingLevel::Max,
_ => return None,
})
}
fn file_arg(arg: &str) -> Option<PathBuf> {
if let Some(rest) = arg.strip_prefix('@') {
if rest.is_empty() {
None
} else {
Some(PathBuf::from(rest))
}
} else {
None
}
}
pub fn parse_args(args: &[String]) -> Args {
let mut result = Args::default();
result.offline = offline_env_enabled();
if let Ok(raw) = std::env::var("RPI_EXTENSIONS_DIR") {
if !raw.is_empty() {
let sep = if cfg!(windows) { ';' } else { ':' };
for part in raw.split(sep) {
let trimmed = part.trim();
if !trimmed.is_empty() {
result.extensions_dir.push(PathBuf::from(trimmed));
}
}
}
}
let mut i = 0;
while i < args.len() {
let arg = args[i].clone();
let (flag_key, inline) = if arg.starts_with("--") {
match arg.find('=') {
Some(eq) => (arg[..eq].to_string(), Some(arg[eq + 1..].to_string())),
None => (arg.clone(), None),
}
} else {
(arg.clone(), None)
};
let mut take_value = |result: &mut Args, _flag: &str| -> Option<String> {
if let Some(v) = inline.clone() {
return Some(v);
}
if i + 1 < args.len() {
let next = &args[i + 1];
if !next.starts_with('-') || next == "-" {
i += 1;
return Some(args[i].clone());
}
}
result.errors.push(format!("{flag_key} requires a value"));
None
};
match flag_key.as_str() {
"--help" | "-h" => result.help = true,
"--version" | "-v" => result.version = true,
"--print" | "-p" => {
result.print = true;
if i + 1 < args.len() {
let next = &args[i + 1];
if !next.starts_with('@') && !next.starts_with('-') {
i += 1;
result.messages.push(args[i].clone());
}
}
}
"--mode" => {
if let Some(v) = take_value(&mut result, "--mode") {
result.mode = match v.as_str() {
"text" => Mode::Text,
"json" => Mode::Json,
"rpc" => Mode::Rpc,
other => {
result.errors.push(format!(
"Invalid --mode \"{other}\". Valid: text, json, rpc"
));
Mode::Text
}
};
}
}
"--tui-mode" => {
if let Some(v) = take_value(&mut result, "--tui-mode") {
result.tui_mode = match v.to_ascii_lowercase().as_str() {
"regular" => TuiMode::Regular,
"fullscreen" => TuiMode::Fullscreen,
other => {
result.errors.push(format!(
"Invalid --tui-mode \"{other}\". Valid: regular, fullscreen"
));
TuiMode::Fullscreen
}
};
}
}
"--continue" | "-c" => result.continue_session = true,
"--resume" | "-r" => result.resume = true,
"--no-session" => result.no_session = true,
"--no-tools" | "-nt" => result.no_tools = true,
"--no-builtin-tools" | "-nbt" => result.no_builtin_tools = true,
"--no-skills" | "-ns" => result.no_skills = true,
"--no-prompt-templates" | "-np" => result.no_prompt_templates = true,
"--no-context-files" | "-nc" => result.no_context_files = true,
"--no-extensions" | "-ne" => result.no_extensions = true,
"--enable-pi-packages" => result.enable_pi_packages = true,
"--extensions-dir" | "-ed" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.extensions_dir.push(PathBuf::from(v));
}
}
"--verbose" => result.verbose = true,
"--debug-system-prompt" => result.debug_system_prompt = true,
"--provider" => result.provider = take_value(&mut result, "--provider"),
"--model" => result.model = take_value(&mut result, "--model"),
"--api-key" => result.api_key = take_value(&mut result, "--api-key"),
"--base-url" => result.base_url = take_value(&mut result, "--base-url"),
"--system-prompt" => result.system_prompt = take_value(&mut result, "--system-prompt"),
"--append-system-prompt" => {
if let Some(v) = take_value(&mut result, "--append-system-prompt") {
result.append_system_prompt.push(v);
}
}
"--name" | "-n" => result.name = take_value(&mut result, "--name"),
"--session" => result.session = take_value(&mut result, "--session"),
"--session-id" => result.session_id = take_value(&mut result, "--session-id"),
"--fork" => result.fork = take_value(&mut result, "--fork"),
"--models" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.models = Some(split_csv(&v));
}
}
"--extension" | "-e" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.extension.push(PathBuf::from(v));
}
}
"--skill" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.skill.push(PathBuf::from(v));
}
}
"--prompt-template" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.prompt_template.push(PathBuf::from(v));
}
}
"--session-dir" => {
if let Some(v) = take_value(&mut result, "--session-dir") {
result.session_dir = Some(PathBuf::from(v));
}
}
"--thinking" => {
if let Some(v) = take_value(&mut result, "--thinking") {
match parse_thinking_level(&v) {
Some(lvl) => result.thinking = Some(lvl),
None => result.ignored.push(format!(
"Invalid --thinking \"{v}\". Valid: {}",
VALID_THINKING_LEVELS.join(", ")
)),
}
}
}
"--tools" | "-t" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.tools = Some(split_csv(&v));
}
}
"--exclude-tools" | "-xt" => {
if let Some(v) = take_value(&mut result, &flag_key) {
result.exclude_tools = Some(split_csv(&v));
}
}
"--list-models" => {
let mut search = inline.clone().unwrap_or_default();
if inline.is_none()
&& i + 1 < args.len()
&& !args[i + 1].starts_with('-')
&& !args[i + 1].starts_with('@')
{
i += 1;
search = args[i].clone();
}
result.list_models = Some(search);
}
"--offline" => result.offline = true,
"--export" => {
if let Some(value) = take_value(&mut result, &flag_key) {
result.export = Some(PathBuf::from(value));
}
}
"--approve" | "-a" => result.trust_override = Some(true),
"--no-approve" | "-na" => result.trust_override = Some(false),
other if matches!(other, "--models") => {
if inline.is_none()
&& i + 1 < args.len()
&& !args[i + 1].starts_with('-')
&& !args[i + 1].starts_with('@')
{
i += 1;
}
result
.ignored
.push(format!("{other} is not supported in v1 (ignored)"));
}
"--theme" => {
result.theme = take_value(&mut result, "--theme");
}
"--no-themes" => result.no_themes = true,
other if other.starts_with("--") => {
let name = &flag_key;
let value = if let Some(value) = inline {
serde_json::Value::String(value)
} else if i + 1 < args.len()
&& !args[i + 1].starts_with('-')
&& !args[i + 1].starts_with('@')
{
i += 1;
serde_json::Value::String(args[i].clone())
} else {
serde_json::Value::Bool(true)
};
result.unknown_flags.insert(name[2..].to_string(), value);
}
other if other.starts_with('-') && other.len() > 1 => {
result.errors.push(format!("Unknown option: {other}"));
}
other => {
if let Some(path) = file_arg(other) {
result.file_args.push(path);
} else {
result.messages.push(other.to_string());
}
}
}
i += 1;
}
result
}
fn split_csv(v: &str) -> Vec<String> {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
pub fn resolve_mode(parsed: &Args, stdin_is_tty: bool, stdout_is_tty: bool) -> RunMode {
if parsed.mode == Mode::Rpc {
return RunMode::Rpc;
}
if parsed.mode == Mode::Json {
return RunMode::Json;
}
if parsed.print || !stdin_is_tty || !stdout_is_tty {
RunMode::Print
} else {
RunMode::Interactive
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunMode {
Interactive,
Print,
Json,
Rpc,
}
pub fn print_help() {
let builtin = "read, bash, edit, write";
println!(
"{name} - AI coding assistant with read, bash, edit, write tools
{u}Usage:{r}
{name} [options] [@files...] [messages...]
{u}Options:{r}
--provider <name> Provider name (anthropic, openai-completions, openai-responses, or models.json id)
--model <pattern> Model pattern or ID (supports \"provider/id\" and optional \":<thinking>\")
--api-key <key> API key override for the selected provider
--base-url <url> Override the selected model endpoint
--system-prompt <text> Replace the default system prompt
--append-system-prompt <text> Append text to the system prompt (repeatable)
--thinking <level> off, minimal, low, medium, high, xhigh, max
--mode <mode> Output mode: text (default), json, or rpc
--tui-mode <mode> Interactive TUI buffer: regular or fullscreen
--list-models [search] List available models (with optional fuzzy search)
--offline Disable startup network operations (same as PI_OFFLINE=1)
--export <file> Export a JSONL session to HTML and exit
--approve, -a Trust the current project for local resources
--no-approve, -na Do not trust the current project
--print, -p Non-interactive: process prompt(s) and exit
--continue, -c Continue the most recent session
--resume, -r Browse and select a session to resume
--session <id|path> Use a specific session (partial UUID or file)
--session-dir <dir> Directory for session storage
--no-session Ephemeral mode (do not persist the session)
--name, -n <name> Set the session display name
--tools, -t <list> Comma-separated allowlist of tool names to enable
--exclude-tools, -xt <list> Comma-separated denylist of tool names to disable
--no-tools, -nt Disable all tools
--no-builtin-tools, -nbt Disable the built-in tools (read, bash, edit, write)
--no-skills, -ns Skip skill discovery (no <available_skills> block)
--no-prompt-templates, -np Skip prompt-template discovery (/expand templates)
--no-context-files, -nc Skip AGENTS.md/CLAUDE.md discovery (no <project_context>)
--no-extensions, -ne Skip Rust cdylib and JS/TS extension loading
--enable-pi-packages Enable configured Pi JS/TS packages (starts Node)
--extensions-dir, -ed <dir> Extra dir to scan for plugins (.dll/.so/.dylib); repeatable
(also via RPI_EXTENSIONS_DIR env: ';' on Windows, ':' on Unix)
--debug-system-prompt Print the resolved system-prompt sections to stderr (verification)
--verbose Show startup warnings (e.g. ignored flags)
--help, -h Show this help
--version, -v Show version
{u}Subcommands:{r}
update Update installed Rust and npm packages
pi-update Update the rpi CLI from crates.io
auth login|check|logout Manage persisted credentials in ~/.rpi/auth.json
(see `rpi auth --help`)
package list|add|remove|update Manage TS packages and Rust extensions
(see `rpi package --help`)
install <crate> Build and install a Rust cdylib extension
(see `rpi install --help`)
install-pi <spec> Install an npm/git/local Pi package
(see `rpi install-pi --help`)
uninstall <crate> Remove an installed Rust cdylib extension
(use `rpi uninstall pi <spec>` for Pi packages)
uninstall-pi <spec> Remove an installed npm/git/local Pi package
(see `rpi uninstall-pi --help`)
dev [options] Build, watch, and hot-reload a Rust extension
(see `rpi dev --help`)
dev-local [options] Debug only the current Rust extension
(shortcut for `rpi dev --local-only`)
{u}Built-in Tools:{r}
{builtin} (enabled by default; Pi-compatible default set)
{u}Examples:{r}
# Interactive with an initial prompt
{name} \"List all .rs files in src/\"
# Single-shot print mode
{name} -p \"Summarize this project\"
# Include a file in the initial message
{name} @README.md \"What does this project do?\"
# Continue the previous session
{name} -c \"What did we discuss?\"
# Use a specific model + thinking level
{name} --model claude-sonnet-5 --thinking high \"Refactor this\"
# JSON event stream (one JSON object per line on stdout)
{name} --mode json -p \"Inspect the code\"
# Read-only: no file-modifying tools
{name} --tools read,bash -p \"Review the code in src/\"
{u}Environment:{r}
ANTHROPIC_API_KEY Anthropic API key (x-api-key) — fallback when no stored credential
ANTHROPIC_AUTH_TOKEN Bearer token (Authorization: Bearer) for third-party gateways
ANTHROPIC_BASE_URL Override the Anthropic endpoint (e.g. a compatible proxy)
OPENAI_API_KEY Bearer token for openai-completions/responses
PI_OFFLINE Disable startup network operations when set to 1/true/yes
RPI_CODING_AGENT_DIR Override the ~/.rpi config directory (auth.json + models.json)
{u}Notes:{r}
Supported HTTP protocols are Anthropic Messages and OpenAI Chat Completions.
Define custom model catalogs and provider apiKey values in
~/.rpi/agent/models.json. The interactive TUI, Rust and JS/TS extensions,
opt-in Pi package resources, skills, prompt templates, themes, model cycling, session
fork/export, and trust commands are
available in the current build. OAuth, RPC, and full model cycling remain
outside the current implementation.
",
name = crate::APP_NAME,
builtin = builtin,
u = "\x1b[1m",
r = "\x1b[0m",
);
}
pub fn print_version() {
println!("{} {}", crate::APP_NAME, crate::VERSION);
}
#[cfg(test)]
mod tests {
use super::*;
struct RestoreOfflineEnv(Option<std::ffi::OsString>);
impl Drop for RestoreOfflineEnv {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var(PI_OFFLINE_ENV, value),
None => std::env::remove_var(PI_OFFLINE_ENV),
}
}
}
fn s(args: &[&str]) -> Vec<String> {
args.iter().map(|a| a.to_string()).collect()
}
#[test]
fn parses_basic_prompt() {
let a = parse_args(&s(&["hello", "world"]));
assert_eq!(a.messages, vec!["hello".to_string(), "world".to_string()]);
assert!(!a.help);
}
#[test]
fn parses_help_and_version() {
let a = parse_args(&s(&["--help"]));
assert!(a.help);
let a = parse_args(&s(&["-v"]));
assert!(a.version);
}
#[test]
fn print_consumes_following_positional() {
let a = parse_args(&s(&["-p", "summarize"]));
assert!(a.print);
assert_eq!(a.messages, vec!["summarize".to_string()]);
}
#[test]
fn print_does_not_consume_file_or_flag() {
let a = parse_args(&s(&["-p", "@file.md"]));
assert!(a.print);
assert!(a.messages.is_empty());
assert_eq!(a.file_args, vec![PathBuf::from("file.md")]);
}
#[test]
fn model_and_thinking() {
let a = parse_args(&s(&["--model", "claude-sonnet-5", "--thinking", "high"]));
assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
assert_eq!(a.thinking, Some(ThinkingLevel::High));
}
#[test]
fn model_with_thinking_shorthand() {
let a = parse_args(&s(&["--model", "claude-sonnet-5:high"]));
assert_eq!(a.model.as_deref(), Some("claude-sonnet-5:high"));
}
#[test]
fn tools_split_csv() {
let a = parse_args(&s(&["--tools", "read, bash ,write"]));
assert_eq!(
a.tools.as_deref(),
Some(&["read".to_string(), "bash".to_string(), "write".to_string()][..])
);
}
#[test]
fn unknown_short_flag_errors() {
let a = parse_args(&s(&["-Z"]));
assert!(!a.errors.is_empty());
}
#[test]
fn unknown_long_flag_is_retained_for_extensions() {
let a = parse_args(&s(&["--frobnicate", "value"]));
assert!(a.errors.is_empty());
assert_eq!(
a.unknown_flags.get("frobnicate"),
Some(&serde_json::Value::String("value".into()))
);
assert!(a.ignored.is_empty());
}
#[test]
fn unknown_long_boolean_flag_is_retained() {
let a = parse_args(&s(&["--server"]));
assert_eq!(
a.unknown_flags.get("server"),
Some(&serde_json::Value::Bool(true))
);
}
#[test]
fn unknown_long_flags_keep_string_and_equals_values() {
let a = parse_args(&s(&["--port", "8080", "--bind=127.0.0.1"]));
assert_eq!(
a.unknown_flags.get("port"),
Some(&serde_json::Value::String("8080".into()))
);
assert_eq!(
a.unknown_flags.get("bind"),
Some(&serde_json::Value::String("127.0.0.1".into()))
);
}
#[test]
fn models_flag_parses_csv() {
let a = parse_args(&s(&["--models", "a,b,c"]));
assert!(a.errors.is_empty());
assert!(a.ignored.is_empty(), "--models is implemented");
assert_eq!(
a.models.as_deref(),
Some(&["a".to_string(), "b".to_string(), "c".to_string()][..])
);
assert!(a.messages.is_empty());
}
#[test]
fn list_models_accepts_bare_and_search_forms() {
let bare = parse_args(&s(&["--list-models"]));
assert_eq!(bare.list_models.as_deref(), Some(""));
assert!(bare.ignored.is_empty());
assert!(bare.messages.is_empty());
let search = parse_args(&s(&["--list-models", "claude"]));
assert_eq!(search.list_models.as_deref(), Some("claude"));
assert!(search.messages.is_empty());
let inline = parse_args(&s(&["--list-models=gpt"]));
assert_eq!(inline.list_models.as_deref(), Some("gpt"));
}
#[test]
fn offline_flag_is_honored_without_warning() {
let args = parse_args(&s(&["--offline"]));
assert!(args.offline);
assert!(args.ignored.is_empty());
}
#[test]
fn native_pi_offline_truthy_values_are_case_insensitive() {
for value in [
Some("1"),
Some("true"),
Some("TRUE"),
Some("Yes"),
Some("yEs"),
] {
assert!(is_truthy_env_flag(value), "value={value:?}");
}
for value in [
None,
Some(""),
Some("0"),
Some("false"),
Some("no"),
Some(" true "),
] {
assert!(!is_truthy_env_flag(value), "value={value:?}");
}
}
#[test]
fn pi_offline_env_and_cli_flag_share_one_normalized_gate() {
let _guard = crate::config::test_support::env_lock().lock().unwrap();
let _restore = RestoreOfflineEnv(std::env::var_os(PI_OFFLINE_ENV));
std::env::set_var(PI_OFFLINE_ENV, "YeS");
assert!(parse_args(&[]).offline);
assert!(normalize_offline_mode(&[]));
assert_eq!(std::env::var(PI_OFFLINE_ENV).as_deref(), Ok("1"));
std::env::set_var(PI_OFFLINE_ENV, "0");
assert!(!parse_args(&[]).offline);
let argv = s(&["package", "update", "--offline"]);
assert!(normalize_offline_mode(&argv));
assert_eq!(std::env::var(PI_OFFLINE_ENV).as_deref(), Ok("1"));
assert_eq!(without_offline_flag(&argv), s(&["package", "update"]));
}
#[test]
fn project_trust_flags_are_honored_without_warning() {
let approved = parse_args(&s(&["--approve"]));
assert_eq!(approved.trust_override, Some(true));
assert!(approved.ignored.is_empty());
let denied = parse_args(&s(&["--no-approve"]));
assert_eq!(denied.trust_override, Some(false));
assert!(denied.ignored.is_empty());
}
#[test]
fn export_flag_captures_input_and_output_position() {
let args = parse_args(&s(&["--export", "session.jsonl", "transcript.html"]));
assert_eq!(args.export, Some(PathBuf::from("session.jsonl")));
assert_eq!(args.messages, vec!["transcript.html".to_string()]);
assert!(args.ignored.is_empty());
}
#[test]
fn session_id_and_fork_flags_parse() {
let a = parse_args(&s(&["--session-id", "01abc", "--fork", "xyz"]));
assert!(a.errors.is_empty());
assert_eq!(a.session_id.as_deref(), Some("01abc"));
assert_eq!(a.fork.as_deref(), Some("xyz"));
let a = parse_args(&s(&[
"-e",
"plugin.dll",
"--skill",
"s",
"--prompt-template",
"t.md",
]));
assert_eq!(a.extension.len(), 1);
assert_eq!(a.skill.len(), 1);
assert_eq!(a.prompt_template.len(), 1);
}
#[test]
fn no_skills_flag_honored() {
let a = parse_args(&s(&["-ns"]));
assert!(a.errors.is_empty());
assert!(a.no_skills);
assert!(a.ignored.is_empty());
}
#[test]
fn no_prompt_templates_flag_honored() {
let a = parse_args(&s(&["--no-prompt-templates"]));
assert!(a.no_prompt_templates);
assert!(a.ignored.is_empty());
}
#[test]
fn no_context_files_flag_honored() {
let a = parse_args(&s(&["-nc"]));
assert!(a.no_context_files);
assert!(a.ignored.is_empty());
}
#[test]
fn no_extensions_flag_honored() {
let a = parse_args(&s(&["--no-extensions"]));
assert!(a.no_extensions);
assert!(a.ignored.is_empty());
}
#[test]
fn pi_packages_are_disabled_by_default_and_explicitly_enabled() {
let a = parse_args(&s(&[]));
assert!(!a.enable_pi_packages);
assert!(a.ignored.is_empty());
let a = parse_args(&s(&["--enable-pi-packages"]));
assert!(a.enable_pi_packages);
assert!(a.ignored.is_empty());
}
#[test]
fn extensions_dir_flag_collects_dirs() {
let a = parse_args(&s(&["--extensions-dir", "/a/b", "-ed", "/c/d"]));
assert_eq!(
a.extensions_dir,
vec![PathBuf::from("/a/b"), PathBuf::from("/c/d")]
);
assert!(a.ignored.is_empty());
}
#[test]
fn extensions_dir_inline_equals_form() {
let a = parse_args(&s(&["--extensions-dir=/x/y"]));
assert_eq!(a.extensions_dir, vec![PathBuf::from("/x/y")]);
}
#[test]
fn extensions_dir_env_is_merged() {
let a = parse_args(&s(&["--extensions-dir", "/flag/only"]));
assert!(a
.extensions_dir
.iter()
.any(|p| p == &PathBuf::from("/flag/only")));
}
#[test]
fn file_args_stripped() {
let a = parse_args(&s(&["@a.txt", "@b.md", "hi"]));
assert_eq!(
a.file_args,
vec![PathBuf::from("a.txt"), PathBuf::from("b.md")]
);
assert_eq!(a.messages, vec!["hi".to_string()]);
}
#[test]
fn equals_form_supported() {
let a = parse_args(&s(&["--model=claude-sonnet-5", "--thinking=low"]));
assert_eq!(a.model.as_deref(), Some("claude-sonnet-5"));
assert_eq!(a.thinking, Some(ThinkingLevel::Low));
}
#[test]
fn theme_flag_is_honored() {
let a = parse_args(&s(&["--theme", "ocean.json"]));
assert_eq!(a.theme.as_deref(), Some("ocean.json"));
assert!(a.ignored.is_empty());
}
#[test]
fn no_themes_is_honored() {
let a = parse_args(&s(&["--no-themes"]));
assert!(a.no_themes);
assert!(a.ignored.is_empty());
}
#[test]
fn tui_mode_parses_and_validates() {
assert_eq!(
parse_args(&s(&["--tui-mode", "regular"])).tui_mode,
TuiMode::Regular
);
assert_eq!(
parse_args(&s(&["--tui-mode=fullscreen"])).tui_mode,
TuiMode::Fullscreen
);
let invalid = parse_args(&s(&["--tui-mode", "split"]));
assert!(!invalid.errors.is_empty());
}
#[test]
fn resolve_mode_interactive_when_tty() {
let a = Args {
print: true,
..Args::default()
};
assert_eq!(resolve_mode(&a, true, true), RunMode::Print);
let a = Args::default();
assert_eq!(resolve_mode(&a, true, true), RunMode::Interactive);
let a = Args {
mode: Mode::Json,
..Args::default()
};
assert_eq!(resolve_mode(&a, true, true), RunMode::Json);
let a = Args {
mode: Mode::Rpc,
..Args::default()
};
assert_eq!(resolve_mode(&a, true, true), RunMode::Rpc);
}
#[test]
fn piped_stdout_forces_print() {
let a = Args::default();
assert_eq!(resolve_mode(&a, true, false), RunMode::Print);
}
}