1pub 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
21pub const MIN_TICK_RATE_MS: u64 = 33;
23
24pub const DEFAULT_TICK_RATE_MS: u64 = MIN_TICK_RATE_MS;
26
27#[derive(ValueEnum, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
29#[repr(u8)]
30#[serde(rename_all = "kebab-case")]
31pub enum Theme {
32 #[default]
34 EldritchMinimal,
35 IcebergDark,
37 CatppuccinMocha,
39}
40
41#[derive(ValueEnum, Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
43#[serde(rename_all = "kebab-case")]
44pub enum WebSearchMode {
45 #[default]
47 #[value(name = "duckduckgo")]
48 #[serde(rename = "duckduckgo")]
49 DuckDuckGo,
50 #[value(name = "searxng")]
52 #[serde(rename = "searxng")]
53 Searxng,
54 None,
56}
57
58#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
64#[serde(rename_all = "snake_case")]
65pub enum ReasoningEffort {
66 #[default]
68 Auto,
69 On,
71 None,
73 Minimal,
75 Low,
77 Medium,
79 High,
81 Xhigh,
83 Max,
85}
86
87impl ReasoningEffort {
88 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 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 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 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 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#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
168#[serde(rename_all = "snake_case")]
169pub enum ReasoningSummary {
170 #[default]
172 Off,
173 Auto,
175}
176
177impl ReasoningSummary {
178 pub fn label(self) -> &'static str {
180 match self {
181 Self::Off => "off",
182 Self::Auto => "auto",
183 }
184 }
185
186 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#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
198pub enum Command {
199 Setup(commands::setup::SetupCommand),
201 Login(commands::auth::LoginCommand),
203 Logout(commands::auth::LogoutCommand),
205 Auth {
207 #[command(subcommand)]
208 command: commands::auth::AuthCommand,
209 },
210 Doctor(commands::doctor::DoctorCommand),
212 Config {
214 #[command(subcommand)]
215 command: commands::config::ConfigCommand,
216 },
217 Acp {
219 #[command(subcommand)]
220 command: commands::acp::AcpCommand,
221 },
222 Mcp {
224 #[command(subcommand)]
225 command: commands::mcp::McpCommand,
226 },
227 Skills {
229 #[command(subcommand)]
230 command: commands::skills::SkillsCommand,
231 },
232 #[command(alias = "sessions")]
234 Session {
235 #[command(subcommand)]
236 command: commands::session::SessionCommand,
237 },
238 Debug {
240 #[command(subcommand)]
241 command: commands::debug::DebugCommand,
242 },
243}
244
245impl WebSearchMode {
246 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#[derive(Parser, Clone, Debug)]
258#[command(version, about = "agentic pair programmer")]
259pub struct Cli {
260 #[arg(long, default_value = ".")]
262 pub cwd: PathBuf,
263 #[arg(long, default_value = "")]
268 pub model: String,
269 #[arg(long, value_enum, default_value = "duckduckgo")]
271 pub websearch: WebSearchMode,
272 #[arg(long = "websearch-url")]
274 pub websearch_url: Option<String>,
275 #[arg(skip)]
280 pub reasoning_effort: ReasoningEffort,
281 #[arg(skip)]
286 pub reasoning_summary: ReasoningSummary,
287 #[arg(long, default_value_t = DEFAULT_TICK_RATE_MS)]
289 pub tick_rate_ms: u64,
290 #[arg(long, default_value_t = false, conflicts_with = "mouse")]
292 pub no_mouse: bool,
293 #[arg(long, default_value_t = false, conflicts_with = "no_mouse")]
299 pub mouse: bool,
300 #[arg(long, default_value_t = false)]
302 pub verbose: bool,
303 #[arg(long, value_enum, default_value = "eldritch-minimal")]
305 pub theme: Theme,
306 #[arg(long, default_value_t = false)]
308 pub print_prompt: bool,
309 #[arg(long = "skill-dir")]
311 pub skill_dirs: Vec<PathBuf>,
312 #[arg(long = "session-dir")]
314 pub session_dir: Option<PathBuf>,
315 #[arg(skip)]
317 pub config_diagnostics: Vec<String>,
318 #[arg(skip)]
320 pub config_layers: Vec<config::LoadedConfigLayer>,
321 #[arg(skip)]
323 pub config_origins: BTreeMap<String, config::ConfigOrigin>,
324 #[arg(skip)]
326 pub acp_agents: config::AcpAgentsConfig,
327 #[command(subcommand)]
329 pub command: Option<Command>,
330}
331
332impl Default for Cli {
333 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 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 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 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}