1use std::path::Path;
7
8use anyhow::Result;
9use clap::CommandFactory;
10use clap_complete::generate;
11
12use crate::app::App;
13use crate::cli_args::{Cli, Commands, VaultCommands};
14use crate::runtime::helpers::{
15 apply_saved_sort, ensure_bw_session, ensure_keychain_password, ensure_proton_login,
16 ensure_vault_ssh_chain_if_needed, expand_user_path, resolve_config_path,
17};
18use crate::ssh_config::model::SshConfigFile;
19use crate::tui_loop::run_tui;
20use crate::{
21 askpass, cli, connection, demo, history, key_activity, logging, mcp, messages, preferences,
22 providers, snippet, ui, update,
23};
24
25pub fn run(cli: Cli) -> Result<()> {
30 let env = std::sync::Arc::new(crate::runtime::env::Env::from_process());
33
34 ui::theme::init(&env);
35
36 let is_cli_subcommand = cli.command.is_some() || cli.list || cli.connect.is_some();
38 logging::init(cli.verbose, is_cli_subcommand);
39
40 if let Some(ref name) = cli.theme {
41 if let Some(theme) = ui::theme::ThemeDef::find_builtin(name).or_else(|| {
42 ui::theme::ThemeDef::load_custom()
43 .into_iter()
44 .find(|t| t.name.eq_ignore_ascii_case(name))
45 }) {
46 ui::theme::set_theme(theme);
47 } else {
48 anyhow::bail!("Unknown theme: {}", name);
49 }
50 }
51
52 if let Some(shell) = cli.completions {
54 let mut cmd = Cli::command();
55 generate(shell, &mut cmd, "purple", &mut std::io::stdout());
56 return Ok(());
57 }
58
59 if cli.demo {
60 let mut app = demo::build_demo_app();
61 demo::seed_whats_new_toast(&mut app);
62 demo::seed_tunnel_live_snapshots(&mut app);
63 return run_tui(app);
64 }
65
66 if let Some(Commands::Provider { command }) = cli.command {
68 return cli::handle_provider_command(&env, command);
69 }
70 if let Some(Commands::Update) = cli.command {
71 return update::self_update();
72 }
73 if let Some(Commands::Password { command }) = cli.command {
74 return cli::handle_password_command(&env, command);
75 }
76 if let Some(Commands::Mcp {
77 read_only,
78 no_audit,
79 audit_log,
80 }) = cli.command
81 {
82 let config_path = resolve_config_path(&cli.config)?;
83 let audit_log_path = if no_audit {
84 None
85 } else if let Some(path) = audit_log {
86 Some(expand_user_path(&path)?)
87 } else {
88 mcp::default_audit_log_path()
89 };
90 let options = mcp::McpOptions {
91 read_only,
92 audit_log_path,
93 };
94 return mcp::run(&config_path, options);
95 }
96 if let Some(Commands::Logs { tail, clear }) = cli.command {
97 return cli::handle_logs_command(tail, clear);
98 }
99 if let Some(Commands::Theme { command }) = cli.command {
100 return cli::handle_theme_command(&env, command);
101 }
102 if let Some(Commands::WhatsNew { since }) = &cli.command {
103 let output = cli::run_whats_new(since.as_deref())?;
104 print!("{}", output);
105 return Ok(());
106 }
107
108 let config_path = resolve_config_path(&cli.config)?;
109 let mut config = SshConfigFile::parse(&config_path)?;
110 let repaired_groups = config.repair_absorbed_group_comments();
111 let orphaned_headers = config.remove_all_orphaned_group_headers();
112
113 write_startup_banner(&config, &config_path, cli.verbose, &env);
114
115 match cli.command {
117 Some(Commands::Add { target, alias, key }) => {
118 return cli::handle_quick_add(config, &target, alias.as_deref(), key.as_deref());
119 }
120 Some(Commands::Import {
121 file,
122 known_hosts,
123 group,
124 }) => {
125 return cli::handle_import(
126 &env,
127 config,
128 file.as_deref(),
129 known_hosts,
130 group.as_deref(),
131 );
132 }
133 Some(Commands::Sync {
134 provider,
135 dry_run,
136 remove,
137 }) => {
138 return cli::handle_sync(&env, config, provider.as_deref(), dry_run, remove);
139 }
140 Some(Commands::Tunnel { command }) => {
141 return cli::handle_tunnel_command(config, command);
142 }
143 Some(Commands::Snippet { command }) => {
144 return cli::handle_snippet_command(&env, config, command, &config_path);
145 }
146 Some(Commands::Vault {
147 command:
148 VaultCommands::Sign {
149 alias,
150 all,
151 vault_addr: cli_vault_addr,
152 },
153 }) => {
154 return cli::handle_vault_sign_command(&env, config, alias, all, cli_vault_addr);
155 }
156 Some(Commands::Provider { .. })
157 | Some(Commands::Update)
158 | Some(Commands::Password { .. })
159 | Some(Commands::Mcp { .. })
160 | Some(Commands::Theme { .. })
161 | Some(Commands::Logs { .. })
162 | Some(Commands::WhatsNew { .. }) => unreachable!(),
163 None => {}
164 }
165
166 if let Some(alias) = cli.connect {
168 run_direct_connect(alias, &mut config, &config_path, &env)?;
169 }
170
171 if cli.list {
173 print_host_list(&config);
174 return Ok(());
175 }
176
177 if let Some(ref alias) = cli.alias {
179 return run_positional_alias(
180 alias,
181 config,
182 &config_path,
183 repaired_groups,
184 orphaned_headers,
185 env,
186 );
187 }
188
189 let mut app = App::with_env(config, std::sync::Arc::clone(&env));
191 app.post_init();
192 apply_saved_sort(&mut app);
193 if repaired_groups > 0 || orphaned_headers > 0 {
194 app.notify(messages::config_repaired(repaired_groups, orphaned_headers));
195 }
196 run_tui(app)
197}
198
199fn write_startup_banner(
204 config: &SshConfigFile,
205 config_path: &Path,
206 verbose: bool,
207 env: &crate::runtime::env::Env,
208) {
209 let level_str = logging::level_name(verbose);
210 let provider_config = providers::config::ProviderConfig::load();
211
212 let provider_names: Vec<String> = provider_config
213 .sections
214 .iter()
215 .map(|s| s.provider().to_string())
216 .collect();
217
218 let askpass_sources: Vec<String> = config
219 .host_entries()
220 .iter()
221 .filter_map(|h| h.askpass.as_ref())
222 .map(|s| s.to_string())
223 .collect::<std::collections::BTreeSet<_>>()
224 .into_iter()
225 .collect();
226
227 let vault_ssh_info = {
228 let has_host_level = config.host_entries().iter().any(|h| h.vault_ssh.is_some());
229 let has_provider_level = provider_config
230 .sections
231 .iter()
232 .any(|s| !s.vault_role.is_empty());
233 if has_host_level || has_provider_level {
234 let addr = config
235 .host_entries()
236 .iter()
237 .find_map(|h| h.vault_addr.clone())
238 .or_else(|| {
239 provider_config
240 .sections
241 .iter()
242 .find(|s| !s.vault_addr.is_empty())
243 .map(|s| s.vault_addr.clone())
244 })
245 .or_else(|| env.vault_addr().map(str::to_string))
246 .unwrap_or_else(|| "not set".to_string());
247 Some(format!("enabled (addr={addr})"))
248 } else {
249 None
250 }
251 };
252
253 let ssh_version = logging::detect_ssh_version();
254 let term = env.term().unwrap_or("unset").to_string();
255 let colorterm = env.colorterm().unwrap_or("unset").to_string();
256 let theme = preferences::load_theme(env.paths()).unwrap_or_else(|| "Purple".to_string());
257 let hosts = config.host_entries().len();
258 let patterns = config.pattern_entries().len();
259 let snippets = snippet::SnippetStore::load().snippets.len();
260 let proxy_env = collect_proxy_env(env);
261
262 logging::write_banner(&logging::BannerInfo {
263 version: env!("CARGO_PKG_VERSION"),
264 config_path: &config_path.display().to_string(),
265 providers: &provider_names,
266 askpass_sources: &askpass_sources,
267 vault_ssh_info: vault_ssh_info.as_deref(),
268 ssh_version: &ssh_version,
269 term: &term,
270 colorterm: &colorterm,
271 level: &level_str,
272 theme: &theme,
273 hosts,
274 patterns,
275 snippets,
276 proxy_env: &proxy_env,
277 });
278}
279
280fn collect_proxy_env(env: &crate::runtime::env::Env) -> String {
284 let set = env.active_proxy_vars();
285 if set.is_empty() {
286 "none".to_string()
287 } else {
288 set.join(",")
289 }
290}
291
292fn run_direct_connect(
296 alias: String,
297 config: &mut SshConfigFile,
298 config_path: &Path,
299 env: &crate::runtime::env::Env,
300) -> Result<()> {
301 let provider_config = providers::config::ProviderConfig::load();
302 let host_entry = config.host_entries().into_iter().find(|h| h.alias == alias);
303 if host_entry.is_some() {
304 if let Some((msg, _is_error)) =
305 ensure_vault_ssh_chain_if_needed(env, &alias, config_path, &provider_config, config)
306 {
307 eprintln!("{}", msg);
308 }
309 }
310 let askpass = host_entry
311 .as_ref()
312 .and_then(|h| h.askpass.clone())
313 .or_else(|| preferences::load_askpass_default(env.paths()));
314 ensure_proton_login(env, askpass.as_deref());
315 let bw_session = ensure_bw_session(env, None, askpass.as_deref());
316 ensure_keychain_password(env, &alias, askpass.as_deref());
317 let result = connection::connect(
318 &alias,
319 config_path,
320 askpass.as_deref(),
321 bw_session.as_deref(),
322 false,
323 )?;
324 let code = result.status.code().unwrap_or(1);
325 if code != 255 {
326 history::ConnectionHistory::load().record(&alias);
327 key_activity::KeyActivityLog::record_oneshot(&alias, key_activity::now_secs());
328 }
329 askpass::cleanup_marker(&alias);
330 std::process::exit(code);
331}
332
333fn run_positional_alias(
337 alias: &str,
338 mut config: SshConfigFile,
339 config_path: &Path,
340 repaired_groups: usize,
341 orphaned_headers: usize,
342 env: std::sync::Arc<crate::runtime::env::Env>,
343) -> Result<()> {
344 let host_opt = config
345 .host_entries()
346 .iter()
347 .find(|h| h.alias == *alias)
348 .cloned();
349 if let Some(host) = host_opt {
350 let provider_config = providers::config::ProviderConfig::load();
351 if let Some((msg, _is_error)) = ensure_vault_ssh_chain_if_needed(
352 &env,
353 &host.alias,
354 config_path,
355 &provider_config,
356 &mut config,
357 ) {
358 eprintln!("{}", msg);
359 }
360 let alias = host.alias.clone();
361 let askpass = host
362 .askpass
363 .clone()
364 .or_else(|| preferences::load_askpass_default(env.paths()));
365 ensure_proton_login(&env, askpass.as_deref());
366 let bw_session = ensure_bw_session(&env, None, askpass.as_deref());
367 ensure_keychain_password(&env, &alias, askpass.as_deref());
368 print!("{}", messages::cli::beaming_up(&alias));
369 let result = connection::connect(
370 &alias,
371 config_path,
372 askpass.as_deref(),
373 bw_session.as_deref(),
374 false,
375 )?;
376 let code = result.status.code().unwrap_or(1);
377 if code != 255 {
378 history::ConnectionHistory::load().record(&alias);
379 key_activity::KeyActivityLog::record_oneshot(&alias, key_activity::now_secs());
380 }
381 askpass::cleanup_marker(&alias);
382 std::process::exit(code);
383 }
384
385 let mut app = App::with_env(config, env);
387 app.post_init();
388 apply_saved_sort(&mut app);
389 if repaired_groups > 0 || orphaned_headers > 0 {
390 app.notify(messages::config_repaired(repaired_groups, orphaned_headers));
391 }
392 app.start_search_with(alias);
393 if app.search.filtered_indices().is_empty() {
394 app.notify(messages::no_exact_match(alias));
395 }
396 run_tui(app)
397}
398
399fn print_host_list(config: &SshConfigFile) {
402 let entries = config.host_entries();
403 if entries.is_empty() {
404 println!("{}", messages::cli::NO_HOSTS);
405 return;
406 }
407 for host in &entries {
408 let user = if host.user.is_empty() {
409 String::new()
410 } else {
411 format!("{}@", host.user)
412 };
413 let port = if host.port == 22 {
414 String::new()
415 } else {
416 format!(":{}", host.port)
417 };
418 println!("{:<20} {}{}{}", host.alias, user, host.hostname, port);
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
429 fn collect_proxy_env_reports_set_vars_and_none() {
430 use crate::runtime::env::Env;
431
432 assert_eq!(collect_proxy_env(&Env::for_test("/tmp/x")), "none");
433
434 let one = Env::for_test("/tmp/x").with_var("HTTPS_PROXY", "http://proxy.example:3128");
435 assert_eq!(collect_proxy_env(&one), "HTTPS_PROXY");
436
437 let two = one.clone().with_var("NO_PROXY", "localhost,127.0.0.1");
438 assert_eq!(collect_proxy_env(&two), "HTTPS_PROXY,NO_PROXY");
439
440 let empty_https = two.with_var("HTTPS_PROXY", "");
442 assert_eq!(collect_proxy_env(&empty_https), "NO_PROXY");
443 }
444}