Skip to main content

thndrs_lib/cli/
mod.rs

1//! Command-line interface for `thndrs`.
2//!
3//! The entrypoint parses raw flags with [`clap`] directly into the flat [`Cli`]
4//! runtime config.
5
6pub mod app;
7pub mod commands;
8pub mod git;
9pub mod input;
10pub mod renderer;
11
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15use clap::parser::ValueSource;
16use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
17use serde::Deserialize;
18
19use crate::config;
20
21/// Smallest event and render interval supported by the direct terminal UI.
22pub const MIN_TICK_RATE_MS: u64 = 33;
23
24/// Default event and render cadence for the direct terminal UI.
25pub const DEFAULT_TICK_RATE_MS: u64 = MIN_TICK_RATE_MS;
26
27/// Built-in UI color theme.
28#[derive(ValueEnum, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
29#[repr(u8)]
30#[serde(rename_all = "kebab-case")]
31pub enum Theme {
32    /// Default high-contrast dark theme.
33    #[default]
34    EldritchMinimal,
35    /// Muted blue-gray dark theme.
36    IcebergDark,
37    /// Catppuccin Mocha dark theme.
38    CatppuccinMocha,
39}
40
41/// Web search policy for a turn.
42#[derive(ValueEnum, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
43#[serde(rename_all = "kebab-case")]
44pub enum WebSearchMode {
45    /// Search through DuckDuckGo's HTML endpoint.
46    #[default]
47    #[value(name = "duckduckgo")]
48    #[serde(rename = "duckduckgo")]
49    DuckDuckGo,
50    /// Search through a configured SearXNG instance.
51    #[value(name = "searxng")]
52    #[serde(rename = "searxng")]
53    Searxng,
54    /// Disable the application-owned web search tool.
55    None,
56}
57
58/// Requested reasoning control for a provider model.
59///
60/// The exact choices are model-specific. [`Auto`](Self::Auto) delegates to the
61/// provider default, while [`On`](Self::On) and [`None`](Self::None) are used by
62/// providers such as Umans that expose a reasoning toggle rather than a depth.
63#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
64#[serde(rename_all = "snake_case")]
65pub enum ReasoningEffort {
66    /// Use the provider's default reasoning behavior without an override.
67    #[default]
68    Auto,
69    /// Explicitly enable provider-native reasoning where it is toggleable.
70    On,
71    /// Disable explicit reasoning for latency-sensitive work.
72    None,
73    /// Minimal reasoning for supporting Responses models.
74    Minimal,
75    /// Quick, well-scoped work.
76    Low,
77    /// Balanced reasoning quality and latency.
78    Medium,
79    /// Difficult multi-step work.
80    High,
81    /// The deepest supported effort for this provider route.
82    Xhigh,
83    /// Maximum effort for the hardest quality-first work.
84    Max,
85}
86
87impl ReasoningEffort {
88    /// Every normalized reasoning control label.
89    pub const ALL: [Self; 9] = [
90        Self::Auto,
91        Self::On,
92        Self::None,
93        Self::Minimal,
94        Self::Low,
95        Self::Medium,
96        Self::High,
97        Self::Xhigh,
98        Self::Max,
99    ];
100
101    /// Stable configuration label.
102    pub fn label(self) -> &'static str {
103        match self {
104            Self::Auto => "auto",
105            Self::On => "on",
106            Self::None => "none",
107            Self::Minimal => "minimal",
108            Self::Low => "low",
109            Self::Medium => "medium",
110            Self::High => "high",
111            Self::Xhigh => "xhigh",
112            Self::Max => "max",
113        }
114    }
115
116    /// User-facing label for picker controls.
117    pub fn display_label(self) -> &'static str {
118        match self {
119            Self::Auto => "Auto",
120            Self::On => "On",
121            Self::None => "None",
122            Self::Minimal => "Minimal",
123            Self::Low => "Low",
124            Self::Medium => "Medium",
125            Self::High => "High",
126            Self::Xhigh => "Extra High",
127            Self::Max => "Max",
128        }
129    }
130
131    /// Short picker description for this reasoning level.
132    pub fn description(self) -> &'static str {
133        match self {
134            Self::Auto => "Use the provider default",
135            Self::On => "Enable provider-native reasoning",
136            Self::None => "Lowest latency; no explicit reasoning",
137            Self::Minimal => "Minimal reasoning for simple work",
138            Self::Low => "Quick, well-scoped work",
139            Self::Medium => "Balanced quality and latency",
140            Self::High => "Difficult multi-step work",
141            Self::Xhigh => "Deep reasoning for complex work",
142            Self::Max => "Hardest quality-first work",
143        }
144    }
145
146    /// Parse a configuration or ACP option value.
147    pub fn parse(value: &str) -> Option<Self> {
148        match value.trim().to_ascii_lowercase().as_str() {
149            "auto" => Some(Self::Auto),
150            "on" | "enabled" => Some(Self::On),
151            "none" => Some(Self::None),
152            "minimal" => Some(Self::Minimal),
153            "low" => Some(Self::Low),
154            "medium" => Some(Self::Medium),
155            "high" => Some(Self::High),
156            "xhigh" | "x-high" | "extra-high" => Some(Self::Xhigh),
157            "max" => Some(Self::Max),
158            _ => None,
159        }
160    }
161}
162
163/// Whether providers render a reasoning summary in the transcript.
164///
165/// TODO: Forward this through every supported provider/model family that advertises
166/// compatible reasoning-summary controls.
167#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
168#[serde(rename_all = "snake_case")]
169pub enum ReasoningSummary {
170    /// Keep reasoning opaque while preserving it for provider continuity.
171    #[default]
172    Off,
173    /// Request the provider's best available reasoning summary.
174    Auto,
175}
176
177impl ReasoningSummary {
178    /// Stable configuration label.
179    pub fn label(self) -> &'static str {
180        match self {
181            Self::Off => "off",
182            Self::Auto => "auto",
183        }
184    }
185
186    /// Parse a configuration or ACP option value.
187    pub fn parse(value: &str) -> Option<Self> {
188        match value.trim().to_ascii_lowercase().as_str() {
189            "off" => Some(Self::Off),
190            "auto" => Some(Self::Auto),
191            _ => None,
192        }
193    }
194}
195
196/// Top-level non-interactive commands.
197#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
198pub enum Command {
199    /// Run first-run setup checks and guided credential setup.
200    Setup(commands::setup::SetupCommand),
201    /// Store a provider API key in the credential store.
202    Login(commands::auth::LoginCommand),
203    /// Remove a provider API key from the credential store.
204    Logout(commands::auth::LogoutCommand),
205    /// Inspect provider authentication state.
206    Auth {
207        #[command(subcommand)]
208        command: commands::auth::AuthCommand,
209    },
210    /// Print safe setup diagnostics.
211    Doctor(commands::doctor::DoctorCommand),
212    /// Inspect and edit configuration files.
213    Config {
214        #[command(subcommand)]
215        command: commands::config::ConfigCommand,
216    },
217    /// Inspect configured ACP agents without opening the TUI.
218    Acp {
219        #[command(subcommand)]
220        command: commands::acp::AcpCommand,
221    },
222    /// Inspect and call configured MCP servers without opening the TUI.
223    Mcp {
224        #[command(subcommand)]
225        command: commands::mcp::McpCommand,
226    },
227    /// Inspect discovered skills and their resolution.
228    Skills {
229        #[command(subcommand)]
230        command: commands::skills::SkillsCommand,
231    },
232    /// Inspect local append-only session history.
233    #[command(alias = "sessions")]
234    Session {
235        #[command(subcommand)]
236        command: commands::session::SessionCommand,
237    },
238    /// Read bounded, redacted local diagnostic logs.
239    Debug {
240        #[command(subcommand)]
241        command: commands::debug::DebugCommand,
242    },
243}
244
245impl WebSearchMode {
246    /// Display/config label for this mode.
247    pub fn label(self) -> &'static str {
248        match self {
249            WebSearchMode::DuckDuckGo => "duckduckgo",
250            WebSearchMode::Searxng => "searxng",
251            WebSearchMode::None => "none",
252        }
253    }
254}
255
256/// Runtime configuration after defaults, TOML, env, and flags are merged.
257#[derive(Parser, Clone, Debug)]
258#[command(version, about = "agentic pair programmer")]
259pub struct Cli {
260    /// Working directory used for context loading and display.
261    #[arg(long, default_value = ".")]
262    pub cwd: PathBuf,
263    /// Model to use for completions.
264    ///
265    /// If omitted, first-run setup asks for a provider and model before the
266    /// coding workspace becomes usable.
267    #[arg(long, default_value = "")]
268    pub model: String,
269    /// Application-owned web search backend.
270    #[arg(long, value_enum, default_value = "duckduckgo")]
271    pub websearch: WebSearchMode,
272    /// Base URL for the SearXNG web search backend.
273    #[arg(long = "websearch-url")]
274    pub websearch_url: Option<String>,
275    /// Configured reasoning effort for supporting provider models.
276    ///
277    /// TODO: Route this through every provider/model family that supports
278    /// reasoning-effort controls.
279    #[arg(skip)]
280    pub reasoning_effort: ReasoningEffort,
281    /// Configured reasoning-summary policy for supporting provider models.
282    ///
283    /// TODO: Route this through every provider/model family that supports
284    /// reasoning-summary controls.
285    #[arg(skip)]
286    pub reasoning_summary: ReasoningSummary,
287    /// Event poll interval in milliseconds (minimum [`MIN_TICK_RATE_MS`]).
288    #[arg(long, default_value_t = DEFAULT_TICK_RATE_MS)]
289    pub tick_rate_ms: u64,
290    /// Disable mouse capture in the alternate-screen interface.
291    #[arg(long, default_value_t = false, conflicts_with = "mouse")]
292    pub no_mouse: bool,
293    /// Enable terminal mouse capture for overlay mouse events.
294    ///
295    /// Enables transcript wheel scrolling and overlay mouse navigation. Most
296    /// terminals retain a modifier-assisted text selection gesture while mouse
297    /// capture is active.
298    #[arg(long, default_value_t = false, conflicts_with = "no_mouse")]
299    pub mouse: bool,
300    /// Show diagnostic transcript rows such as provider events and log paths.
301    #[arg(long, default_value_t = false)]
302    pub verbose: bool,
303    /// UI color theme.
304    #[arg(long, value_enum, default_value = "eldritch-minimal")]
305    pub theme: Theme,
306    /// Print the assembled prompt bundle/lowered messages with secrets redacted.
307    #[arg(long, default_value_t = false)]
308    pub print_prompt: bool,
309    /// Additional skill directories to scan.
310    #[arg(long = "skill-dir")]
311    pub skill_dirs: Vec<PathBuf>,
312    /// Directory for append-only session JSONL files.
313    #[arg(long = "session-dir")]
314    pub session_dir: Option<PathBuf>,
315    /// Config diagnostics from effective config loading.
316    #[arg(skip)]
317    pub config_diagnostics: Vec<String>,
318    /// Effective config layers for session metadata.
319    #[arg(skip)]
320    pub config_layers: Vec<config::LoadedConfigLayer>,
321    /// Effective config key origins for session metadata.
322    #[arg(skip)]
323    pub config_origins: BTreeMap<String, config::ConfigOrigin>,
324    /// Effective external ACP agent configs.
325    #[arg(skip)]
326    pub acp_agents: config::AcpAgentsConfig,
327    /// Optional non-interactive command.
328    #[command(subcommand)]
329    pub command: Option<Command>,
330}
331
332impl Default for Cli {
333    /// Used by tests and as the implicit baseline before flag overrides.
334    fn default() -> Self {
335        Cli {
336            cwd: PathBuf::from("."),
337            model: String::new(),
338            websearch: WebSearchMode::DuckDuckGo,
339            websearch_url: None,
340            reasoning_effort: ReasoningEffort::default(),
341            reasoning_summary: ReasoningSummary::default(),
342            tick_rate_ms: DEFAULT_TICK_RATE_MS,
343            no_mouse: false,
344            mouse: false,
345            verbose: false,
346            theme: Theme::default(),
347            print_prompt: false,
348            skill_dirs: Vec::new(),
349            session_dir: None,
350            config_diagnostics: Vec::new(),
351            config_layers: Vec::new(),
352            config_origins: BTreeMap::new(),
353            acp_agents: BTreeMap::new(),
354            command: None,
355        }
356    }
357}
358
359impl Cli {
360    /// Parse command-line arguments, load TOML config, and merge them.
361    pub fn parse_configured() -> Result<Self, config::ConfigError> {
362        match Self::try_parse_configured_from(std::env::args_os()) {
363            Ok(cli) => cli,
364            Err(err) => err.exit(),
365        }
366    }
367
368    /// Test-friendly parser that applies CLI defaults but skips config loading.
369    pub fn try_parse_from<I, T>(itr: I) -> Result<Self, clap::Error>
370    where
371        I: IntoIterator<Item = T>,
372        T: Into<std::ffi::OsString> + Clone,
373    {
374        let (cli, _) = Self::try_parse_matches_from(itr)?;
375        Ok(cli)
376    }
377
378    fn try_parse_configured_from<I, T>(itr: I) -> Result<Result<Self, config::ConfigError>, clap::Error>
379    where
380        I: IntoIterator<Item = T>,
381        T: Into<std::ffi::OsString> + Clone,
382    {
383        let env_vars: Vec<(String, String)> = std::env::vars().filter(|(key, _)| key.starts_with("THNDRS_")).collect();
384        Self::try_parse_configured_from_env(itr, &env_vars)
385    }
386
387    fn try_parse_configured_from_env<I, T>(
388        itr: I, env_vars: &[(String, String)],
389    ) -> Result<Result<Self, config::ConfigError>, clap::Error>
390    where
391        I: IntoIterator<Item = T>,
392        T: Into<std::ffi::OsString> + Clone,
393    {
394        let (cli, matches) = Self::try_parse_matches_from(itr)?;
395        let configured_default_workspace;
396        let initial_workspace = if is_command_line(&matches, "cwd") {
397            cli.cwd.as_path()
398        } else {
399            configured_default_workspace = match config::default_workspace_before_project_config(env_vars) {
400                Ok(path) => path,
401                Err(err) => return Ok(Err(err)),
402            };
403            configured_default_workspace.as_path()
404        };
405        Ok(
406            config::load_effective(initial_workspace, env_vars)
407                .map(|effective| cli.with_effective(effective, &matches)),
408        )
409    }
410
411    fn try_parse_matches_from<I, T>(itr: I) -> Result<(Self, clap::ArgMatches), clap::Error>
412    where
413        I: IntoIterator<Item = T>,
414        T: Into<std::ffi::OsString> + Clone,
415    {
416        let matches = Self::command().try_get_matches_from(itr)?;
417        let cli = Self::from_arg_matches(&matches)?;
418        Ok((cli, matches))
419    }
420
421    fn with_effective(mut self, effective: config::EffectiveConfig, matches: &clap::ArgMatches) -> Self {
422        let defaults = Self::default();
423        let mut config = effective.config;
424        let mut origins = effective.origins;
425
426        if is_command_line(matches, "model") {
427            config.model = Some(self.model.clone());
428            insert_cli_origin(&mut origins, "model", "--model");
429        }
430        if is_command_line(matches, "websearch") {
431            config.websearch = Some(self.websearch);
432            insert_cli_origin(&mut origins, "websearch", "--websearch");
433        }
434        if is_command_line(matches, "websearch_url") {
435            config.websearch_url = self.websearch_url.clone();
436            insert_cli_origin(&mut origins, "websearch_url", "--websearch-url");
437        }
438        if is_command_line(matches, "tick_rate_ms") {
439            config.tick_rate_ms = Some(self.tick_rate_ms);
440            insert_cli_origin(&mut origins, "tick_rate_ms", "--tick-rate-ms");
441        }
442        if is_command_line(matches, "theme") {
443            config.theme = Some(self.theme);
444            insert_cli_origin(&mut origins, "theme", "--theme");
445        }
446        if is_command_line(matches, "mouse") {
447            config.mouse = Some(true);
448            insert_cli_origin(&mut origins, "mouse", "--mouse");
449        } else if is_command_line(matches, "no_mouse") {
450            config.mouse = Some(false);
451            insert_cli_origin(&mut origins, "mouse", "--no-mouse");
452        }
453        if is_command_line(matches, "verbose") {
454            config.verbose = Some(true);
455            insert_cli_origin(&mut origins, "verbose", "--verbose");
456        }
457        if is_command_line(matches, "skill_dirs") && !self.skill_dirs.is_empty() {
458            config
459                .skill_dirs
460                .extend(self.skill_dirs.iter().map(|dir| config::resolve_cli_path(dir)));
461            config::deduplicate_paths(&mut config.skill_dirs);
462            insert_cli_origin(&mut origins, "skill_dirs", "--skill-dir");
463        }
464        if is_command_line(matches, "session_dir")
465            && let Some(ref session_dir) = self.session_dir
466        {
467            config.session_dir = Some(config::resolve_cli_path(session_dir));
468            insert_cli_origin(&mut origins, "session_dir", "--session-dir");
469        }
470
471        self.cwd = if is_command_line(matches, "cwd") {
472            self.cwd
473        } else {
474            config.default_workspace.unwrap_or(defaults.cwd)
475        };
476        self.model = config.model.unwrap_or(defaults.model);
477        self.websearch = config.websearch.unwrap_or(defaults.websearch);
478        self.websearch_url = config.websearch_url.or(defaults.websearch_url);
479        self.reasoning_effort = config.reasoning_effort.unwrap_or(defaults.reasoning_effort);
480        self.reasoning_summary = config.reasoning_summary.unwrap_or(defaults.reasoning_summary);
481        self.tick_rate_ms = config.tick_rate_ms.unwrap_or(defaults.tick_rate_ms);
482        self.mouse = config.mouse.unwrap_or(defaults.mouse);
483        self.verbose = config.verbose.unwrap_or(defaults.verbose);
484        self.theme = config.theme.unwrap_or(defaults.theme);
485        self.skill_dirs = config.skill_dirs;
486        self.session_dir = config.session_dir;
487        self.config_diagnostics = effective.diagnostics;
488        self.config_layers = effective.layers;
489        self.config_origins = origins;
490        self.acp_agents = config.acp_agents;
491        self
492    }
493}
494
495fn is_command_line(matches: &clap::ArgMatches, id: &str) -> bool {
496    matches.value_source(id) == Some(ValueSource::CommandLine)
497}
498
499fn insert_cli_origin(origins: &mut BTreeMap<String, config::ConfigOrigin>, key: &str, flag: &str) {
500    origins.insert(
501        key.to_string(),
502        config::ConfigOrigin { source: config::ConfigSource::CliFlag, detail: flag.to_string() },
503    );
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use std::fs;
510
511    #[test]
512    fn cli_defaults_match_spec() {
513        let cli = Cli::try_parse_from(["thndrs"]).expect("default parse");
514        assert_eq!(cli.cwd, PathBuf::from("."));
515        assert!(cli.model.is_empty());
516        assert_eq!(cli.websearch, WebSearchMode::DuckDuckGo);
517        assert_eq!(cli.tick_rate_ms, DEFAULT_TICK_RATE_MS);
518        assert!(!cli.no_mouse);
519        assert!(!cli.mouse);
520        assert!(!cli.verbose);
521        assert_eq!(cli.theme, Theme::EldritchMinimal);
522        assert!(cli.skill_dirs.is_empty());
523    }
524
525    #[test]
526    fn cli_flags_override_config_values() {
527        let tmp = tempfile::tempdir().expect("create temp dir");
528        let workspace = tmp.path();
529        fs::create_dir_all(workspace.join(".thndrs")).expect("create .thndrs dir");
530        fs::write(
531            workspace.join(".thndrs").join("config.toml"),
532            "model = \"config-model\"\nwebsearch = \"duckduckgo\"\ntick_rate_ms = 250\nverbose = false\ntheme = \"eldritch-minimal\"\n",
533        )
534        .expect("write config");
535
536        let (cli, matches) = Cli::try_parse_matches_from([
537            "thndrs",
538            "--model",
539            "cli-model",
540            "--verbose",
541            "--theme",
542            "catppuccin-mocha",
543        ])
544        .unwrap();
545        let effective = config::load_effective(workspace, &[]).expect("load effective");
546        let cli = cli.with_effective(effective, &matches);
547
548        assert_eq!(cli.model, "cli-model");
549        assert_eq!(cli.websearch, WebSearchMode::DuckDuckGo);
550        assert_eq!(cli.tick_rate_ms, 250);
551        assert!(cli.verbose);
552        assert_eq!(cli.theme, Theme::CatppuccinMocha);
553        assert_eq!(
554            cli.config_origins.get("model"),
555            Some(&config::ConfigOrigin { source: config::ConfigSource::CliFlag, detail: "--model".to_string() })
556        );
557    }
558
559    #[test]
560    fn cli_mouse_flag_overrides_env() {
561        let tmp = tempfile::tempdir().expect("create temp dir");
562        let (cli, matches) = Cli::try_parse_matches_from(["thndrs", "--no-mouse"]).unwrap();
563        let effective =
564            config::load_effective(tmp.path(), &[("THNDRS_MOUSE".to_string(), "true".to_string())]).unwrap();
565        let cli = cli.with_effective(effective, &matches);
566
567        assert!(!cli.mouse);
568        assert_eq!(
569            cli.config_origins.get("mouse"),
570            Some(&config::ConfigOrigin { source: config::ConfigSource::CliFlag, detail: "--no-mouse".to_string() })
571        );
572    }
573
574    #[test]
575    fn cli_model_flag_overrides_env() {
576        let tmp = tempfile::tempdir().expect("create temp dir");
577        let (cli, matches) = Cli::try_parse_matches_from(["thndrs", "--model", "cli-model"]).unwrap();
578        let effective =
579            config::load_effective(tmp.path(), &[("THNDRS_MODEL".to_string(), "env-model".to_string())]).unwrap();
580        let cli = cli.with_effective(effective, &matches);
581
582        assert_eq!(cli.model, "cli-model");
583        assert_eq!(
584            cli.config_origins.get("model"),
585            Some(&config::ConfigOrigin { source: config::ConfigSource::CliFlag, detail: "--model".to_string() })
586        );
587    }
588
589    #[test]
590    fn default_workspace_discovers_project_config_when_cwd_omitted() {
591        let tmp = tempfile::tempdir().expect("create temp dir");
592        let workspace = tmp.path().join("workspace");
593        fs::create_dir_all(workspace.join(".thndrs")).expect("create project config dir");
594        fs::write(
595            workspace.join(".thndrs").join("config.toml"),
596            "model = \"project-model\"\n",
597        )
598        .expect("write project config");
599
600        let env_vars = [("THNDRS_DEFAULT_WORKSPACE".to_string(), workspace.display().to_string())];
601        let cli = Cli::try_parse_configured_from_env(["thndrs"], &env_vars)
602            .expect("parse args")
603            .expect("load config");
604
605        assert_eq!(cli.cwd, workspace);
606        assert_eq!(cli.model, "project-model");
607        assert_eq!(
608            cli.config_origins.get("model"),
609            Some(&config::ConfigOrigin {
610                source: config::ConfigSource::ProjectFile,
611                detail: ".thndrs/config.toml".to_string()
612            })
613        );
614    }
615
616    #[test]
617    fn configured_cli_carries_acp_agents() {
618        let tmp = tempfile::tempdir().expect("create temp dir");
619        let workspace = tmp.path().join("workspace");
620        fs::create_dir_all(workspace.join(".thndrs")).expect("create project config dir");
621        fs::write(
622            workspace.join(".thndrs").join("config.toml"),
623            r#"
624            model = "acp:local"
625
626            [acp_agents.local]
627            command = "agent"
628            args = ["--stdio"]
629            "#,
630        )
631        .expect("write project config");
632
633        let env_vars = [("THNDRS_DEFAULT_WORKSPACE".to_string(), workspace.display().to_string())];
634        let cli = Cli::try_parse_configured_from_env(["thndrs"], &env_vars)
635            .expect("parse args")
636            .expect("load config");
637
638        assert_eq!(cli.model, "acp:local");
639        assert_eq!(cli.acp_agents["local"].command, "agent");
640        assert_eq!(cli.acp_agents["local"].args, vec!["--stdio"]);
641    }
642
643    #[test]
644    // TODO: this should be a table drive test
645    fn websearch_explicit_values_parse() {
646        let duckduckgo = Cli::try_parse_from(["thndrs", "--websearch", "duckduckgo"]).unwrap();
647        assert_eq!(duckduckgo.websearch, WebSearchMode::DuckDuckGo);
648
649        let searxng = Cli::try_parse_from(["thndrs", "--websearch", "searxng"]).unwrap();
650        assert_eq!(searxng.websearch, WebSearchMode::Searxng);
651
652        let none = Cli::try_parse_from(["thndrs", "--websearch", "none"]).unwrap();
653        assert_eq!(none.websearch, WebSearchMode::None);
654    }
655
656    #[test]
657    fn invalid_websearch_is_rejected() {
658        let result = Cli::try_parse_from(["thndrs", "--websearch", "totally-bogus"]);
659        assert!(result.is_err(), "invalid websearch mode should be rejected");
660    }
661
662    #[test]
663    fn model_and_cwd_overrides_parse() {
664        let cli = Cli::try_parse_from([
665            "thndrs",
666            "--cwd",
667            "/tmp/repo",
668            "--model",
669            "umans-glm-5.2",
670            "--tick-rate-ms",
671            "250",
672        ])
673        .expect("explicit flags parse");
674        assert_eq!(cli.cwd, PathBuf::from("/tmp/repo"));
675        assert_eq!(cli.model, "umans-glm-5.2");
676        assert_eq!(cli.tick_rate_ms, 250);
677    }
678
679    #[test]
680    fn print_prompt_flag_parses() {
681        let cli = Cli::try_parse_from(["thndrs", "--print-prompt"]).expect("parse");
682        assert!(cli.print_prompt);
683    }
684
685    #[test]
686    fn acp_list_command_parses() {
687        let cli = Cli::try_parse_from(["thndrs", "acp", "list"]).expect("parse");
688        assert_eq!(
689            cli.command,
690            Some(Command::Acp { command: commands::acp::AcpCommand::List })
691        );
692    }
693
694    #[test]
695    fn acp_serve_command_parses() {
696        let cli = Cli::try_parse_from(["thndrs", "--cwd", "/workspace", "acp", "serve"]).expect("parse");
697
698        assert_eq!(cli.cwd, PathBuf::from("/workspace"));
699        assert_eq!(
700            cli.command,
701            Some(Command::Acp { command: commands::acp::AcpCommand::Serve })
702        );
703    }
704
705    #[test]
706    fn first_run_setup_command_parses() {
707        let cli = Cli::try_parse_from(["thndrs", "setup", "--provider", "umans", "--project"]).expect("parse");
708        assert_eq!(
709            cli.command,
710            Some(Command::Setup(commands::setup::SetupCommand {
711                provider: Some(commands::setup::SetupProviderArg::Umans),
712                global: false,
713                project: true,
714            }))
715        );
716    }
717
718    #[test]
719    fn auth_commands_parse() {
720        let login = Cli::try_parse_from(["thndrs", "login", "opencode-go"]).expect("parse");
721        assert_eq!(
722            login.command,
723            Some(Command::Login(commands::auth::LoginCommand {
724                provider: commands::setup::SetupProviderArg::OpencodeGo,
725                oauth_method: commands::auth::ChatGptOAuthMethod::Browser,
726            }))
727        );
728
729        let zen_login = Cli::try_parse_from(["thndrs", "login", "opencode-zen"]).expect("parse");
730        assert_eq!(
731            zen_login.command,
732            Some(Command::Login(commands::auth::LoginCommand {
733                provider: commands::setup::SetupProviderArg::OpencodeZen,
734                oauth_method: commands::auth::ChatGptOAuthMethod::Browser,
735            }))
736        );
737
738        let codex_login = Cli::try_parse_from(["thndrs", "login", "chatgpt-codex"]).expect("parse");
739        assert_eq!(
740            codex_login.command,
741            Some(Command::Login(commands::auth::LoginCommand {
742                provider: commands::setup::SetupProviderArg::ChatgptCodex,
743                oauth_method: commands::auth::ChatGptOAuthMethod::Browser,
744            }))
745        );
746
747        let device_login = Cli::try_parse_from(["thndrs", "login", "chatgpt-codex", "--oauth-method", "device-code"])
748            .expect("parse explicit device-code login");
749        assert_eq!(
750            device_login.command,
751            Some(Command::Login(commands::auth::LoginCommand {
752                provider: commands::setup::SetupProviderArg::ChatgptCodex,
753                oauth_method: commands::auth::ChatGptOAuthMethod::DeviceCode,
754            }))
755        );
756
757        let logout = Cli::try_parse_from(["thndrs", "logout", "umans"]).expect("parse");
758        assert_eq!(
759            logout.command,
760            Some(Command::Logout(commands::auth::LogoutCommand {
761                provider: commands::setup::SetupProviderArg::Umans,
762            }))
763        );
764
765        let status = Cli::try_parse_from(["thndrs", "auth", "status"]).expect("parse");
766        assert_eq!(
767            status.command,
768            Some(Command::Auth { command: commands::auth::AuthCommand::Status })
769        );
770    }
771
772    #[test]
773    fn doctor_command_parses() {
774        let cli = Cli::try_parse_from(["thndrs", "doctor", "--json"]).expect("parse");
775        assert_eq!(
776            cli.command,
777            Some(Command::Doctor(commands::doctor::DoctorCommand { json: true }))
778        );
779    }
780
781    #[test]
782    fn skills_doctor_command_parses() {
783        let cli = Cli::try_parse_from(["thndrs", "skills", "doctor"]).expect("parse");
784        assert_eq!(
785            cli.command,
786            Some(Command::Skills { command: commands::skills::SkillsCommand::Doctor })
787        );
788    }
789
790    #[test]
791    fn config_commands_parse() {
792        let path = Cli::try_parse_from(["thndrs", "config", "path"]).expect("parse");
793        assert_eq!(
794            path.command,
795            Some(Command::Config { command: commands::config::ConfigCommand::Path })
796        );
797
798        let show = Cli::try_parse_from(["thndrs", "config", "show", "--redacted"]).expect("parse");
799        assert_eq!(
800            show.command,
801            Some(Command::Config {
802                command: commands::config::ConfigCommand::Show(commands::config::ConfigShowCommand { redacted: true })
803            })
804        );
805
806        let edit = Cli::try_parse_from(["thndrs", "config", "edit", "--global"]).expect("parse");
807        assert_eq!(
808            edit.command,
809            Some(Command::Config {
810                command: commands::config::ConfigCommand::Edit(commands::config::ConfigEditCommand {
811                    global: true,
812                    project: false,
813                })
814            })
815        );
816
817        let edit = Cli::try_parse_from(["thndrs", "config", "edit", "--project"]).expect("parse");
818        assert_eq!(
819            edit.command,
820            Some(Command::Config {
821                command: commands::config::ConfigCommand::Edit(commands::config::ConfigEditCommand {
822                    global: false,
823                    project: true,
824                })
825            })
826        );
827    }
828
829    #[test]
830    fn acp_inspect_command_parses() {
831        let cli = Cli::try_parse_from(["thndrs", "acp", "inspect", "local"]).expect("parse");
832        assert_eq!(
833            cli.command,
834            Some(Command::Acp { command: commands::acp::AcpCommand::Inspect { name: "local".to_string() } })
835        );
836    }
837
838    #[test]
839    fn acp_smoke_command_parses_prompt() {
840        let cli = Cli::try_parse_from(["thndrs", "acp", "smoke", "local", "--prompt", "hello"]).expect("parse");
841        assert_eq!(
842            cli.command,
843            Some(Command::Acp {
844                command: commands::acp::AcpCommand::Smoke { name: "local".to_string(), prompt: "hello".to_string() }
845            })
846        );
847    }
848
849    #[test]
850    fn acp_logout_command_parses() {
851        let cli = Cli::try_parse_from(["thndrs", "acp", "logout", "local"]).expect("parse");
852        assert_eq!(
853            cli.command,
854            Some(Command::Acp { command: commands::acp::AcpCommand::Logout { name: "local".to_string() } })
855        );
856    }
857
858    #[test]
859    fn acp_session_commands_parse() {
860        let list = Cli::try_parse_from(["thndrs", "acp", "list-sessions", "local"]).expect("parse");
861        assert_eq!(
862            list.command,
863            Some(Command::Acp { command: commands::acp::AcpCommand::ListSessions { name: "local".to_string() } })
864        );
865
866        let load = Cli::try_parse_from(["thndrs", "acp", "load-session", "local", "external-1"]).expect("parse");
867        assert_eq!(
868            load.command,
869            Some(Command::Acp {
870                command: commands::acp::AcpCommand::LoadSession {
871                    name: "local".to_string(),
872                    session_id: "external-1".to_string()
873                }
874            })
875        );
876
877        let resume = Cli::try_parse_from(["thndrs", "acp", "resume-session", "local", "external-1"]).expect("parse");
878        assert_eq!(
879            resume.command,
880            Some(Command::Acp {
881                command: commands::acp::AcpCommand::ResumeSession {
882                    name: "local".to_string(),
883                    session_id: "external-1".to_string()
884                }
885            })
886        );
887
888        let close = Cli::try_parse_from(["thndrs", "acp", "close-session", "local", "external-1"]).expect("parse");
889        assert_eq!(
890            close.command,
891            Some(Command::Acp {
892                command: commands::acp::AcpCommand::CloseSession {
893                    name: "local".to_string(),
894                    session_id: "external-1".to_string()
895                }
896            })
897        );
898    }
899
900    #[test]
901    fn acp_registry_command_parses() {
902        let cli = Cli::try_parse_from(["thndrs", "acp", "registry", "--file", "registry.json"]).expect("parse");
903        assert_eq!(
904            cli.command,
905            Some(Command::Acp {
906                command: commands::acp::AcpCommand::Registry { file: Some(PathBuf::from("registry.json")) }
907            })
908        );
909    }
910
911    #[test]
912    fn acp_install_and_update_commands_parse() {
913        let install = Cli::try_parse_from([
914            "thndrs",
915            "acp",
916            "install",
917            "codex-acp",
918            "--name",
919            "codex",
920            "--file",
921            "registry.json",
922            "--yes",
923        ])
924        .expect("parse install");
925        assert_eq!(
926            install.command,
927            Some(Command::Acp {
928                command: commands::acp::AcpCommand::Install {
929                    agent_id: "codex-acp".to_string(),
930                    name: Some("codex".to_string()),
931                    file: Some(PathBuf::from("registry.json")),
932                    yes: true,
933                }
934            })
935        );
936
937        let update = Cli::try_parse_from(["thndrs", "acp", "update", "codex", "--file", "registry.json", "--yes"])
938            .expect("parse update");
939        assert_eq!(
940            update.command,
941            Some(Command::Acp {
942                command: commands::acp::AcpCommand::Update {
943                    name: "codex".to_string(),
944                    file: Some(PathBuf::from("registry.json")),
945                    yes: true,
946                }
947            })
948        );
949    }
950
951    #[test]
952    fn no_mouse_flag_parses() {
953        let cli = Cli::try_parse_from(["thndrs", "--no-mouse"]).expect("parse");
954        assert!(cli.no_mouse);
955    }
956
957    #[test]
958    fn mouse_flag_parses() {
959        let cli = Cli::try_parse_from(["thndrs", "--mouse"]).expect("parse");
960        assert!(cli.mouse);
961    }
962
963    #[test]
964    fn mouse_and_no_mouse_conflict() {
965        let err = Cli::try_parse_from(["thndrs", "--mouse", "--no-mouse"]).expect_err("conflict rejected");
966        assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
967    }
968
969    #[test]
970    fn verbose_flag_parses() {
971        let cli = Cli::try_parse_from(["thndrs", "--verbose"]).expect("parse");
972        assert!(cli.verbose);
973    }
974
975    #[test]
976    fn theme_flag_parses() {
977        let cli = Cli::try_parse_from(["thndrs", "--theme", "iceberg-dark"]).expect("parse");
978        assert_eq!(cli.theme, Theme::IcebergDark);
979
980        let cli = Cli::try_parse_from(["thndrs", "--theme", "catppuccin-mocha"]).expect("parse");
981        assert_eq!(cli.theme, Theme::CatppuccinMocha);
982    }
983
984    #[test]
985    fn print_prompt_defaults_false() {
986        let cli = Cli::try_parse_from(["thndrs"]).expect("parse");
987        assert!(!cli.print_prompt);
988    }
989
990    #[test]
991    fn websearch_labels_are_application_backends() {
992        assert_eq!(WebSearchMode::DuckDuckGo.label(), "duckduckgo");
993        assert_eq!(WebSearchMode::Searxng.label(), "searxng");
994        assert_eq!(WebSearchMode::None.label(), "none");
995    }
996
997    #[test]
998    fn mcp_commands_parse() {
999        let list = Cli::try_parse_from(["thndrs", "mcp", "list"]).expect("parse");
1000        assert_eq!(
1001            list.command,
1002            Some(Command::Mcp { command: commands::mcp::McpCommand::List })
1003        );
1004
1005        let test = Cli::try_parse_from(["thndrs", "mcp", "test", "docs"]).expect("parse");
1006        assert_eq!(
1007            test.command,
1008            Some(Command::Mcp { command: commands::mcp::McpCommand::Test { name: "docs".to_string() } })
1009        );
1010
1011        let tools = Cli::try_parse_from(["thndrs", "mcp", "tools", "docs"]).expect("parse");
1012        assert_eq!(
1013            tools.command,
1014            Some(Command::Mcp { command: commands::mcp::McpCommand::Tools { name: "docs".to_string() } })
1015        );
1016
1017        let call = Cli::try_parse_from(["thndrs", "mcp", "call", "docs", "echo", "--json", r#"{"text":"ok"}"#])
1018            .expect("parse");
1019        assert_eq!(
1020            call.command,
1021            Some(Command::Mcp {
1022                command: commands::mcp::McpCommand::Call {
1023                    server: "docs".to_string(),
1024                    tool: "echo".to_string(),
1025                    json: r#"{"text":"ok"}"#.to_string()
1026                }
1027            })
1028        );
1029    }
1030
1031    #[test]
1032    fn session_commands_parse() {
1033        let list = Cli::try_parse_from(["thndrs", "sessions", "list"]).expect("parse");
1034        assert_eq!(
1035            list.command,
1036            Some(Command::Session { command: commands::session::SessionCommand::List })
1037        );
1038
1039        let latest = Cli::try_parse_from(["thndrs", "session", "latest"]).expect("parse");
1040        assert_eq!(
1041            latest.command,
1042            Some(Command::Session { command: commands::session::SessionCommand::Latest })
1043        );
1044
1045        let titles = Cli::try_parse_from(["thndrs", "session", "titles"]).expect("parse");
1046        assert_eq!(
1047            titles.command,
1048            Some(Command::Session { command: commands::session::SessionCommand::Titles })
1049        );
1050
1051        let show = Cli::try_parse_from(["thndrs", "session", "show", "session-1"]).expect("parse");
1052        assert_eq!(
1053            show.command,
1054            Some(Command::Session {
1055                command: commands::session::SessionCommand::Show { session_id: "session-1".to_string() }
1056            })
1057        );
1058
1059        let resume = Cli::try_parse_from(["thndrs", "sessions", "resume", "session-1"]).expect("parse");
1060        assert_eq!(
1061            resume.command,
1062            Some(Command::Session {
1063                command: commands::session::SessionCommand::Resume { session_id: "session-1".to_string() }
1064            })
1065        );
1066
1067        let inspect =
1068            Cli::try_parse_from(["thndrs", "sessions", "inspect", "session-1", "--format", "json"]).expect("parse");
1069        assert_eq!(
1070            inspect.command,
1071            Some(Command::Session {
1072                command: commands::session::SessionCommand::Inspect {
1073                    session_id: "session-1".to_string(),
1074                    format: commands::session::SessionDataFormat::Json,
1075                }
1076            })
1077        );
1078
1079        let export =
1080            Cli::try_parse_from(["thndrs", "sessions", "export", "session-1", "--format", "jsonl"]).expect("parse");
1081        assert_eq!(
1082            export.command,
1083            Some(Command::Session {
1084                command: commands::session::SessionCommand::Export {
1085                    session_id: "session-1".to_string(),
1086                    format: commands::session::SessionDataFormat::Jsonl,
1087                }
1088            })
1089        );
1090
1091        let debug =
1092            Cli::try_parse_from(["thndrs", "debug", "session-log", "session-1", "--lines", "10"]).expect("parse");
1093        assert_eq!(
1094            debug.command,
1095            Some(Command::Debug {
1096                command: commands::debug::DebugCommand::SessionLog { session_id: "session-1".to_string(), lines: 10 }
1097            })
1098        );
1099    }
1100}