1#![forbid(unsafe_code)]
4mod commands;
15mod path_parse;
16mod schema_cmd;
17mod scp_args;
18mod sftp_args;
19mod vps_action;
20
21pub use commands::{
22 Command, LocaleAction, SecretsAction, TlsAcmeAccountAction, TlsAcmeAction, TlsAction,
23 TlsMtlsAction,
24};
25pub(crate) use path_parse::{parse_exec_target, parse_hosts_list, parse_scp_target, ScpPathPlan};
26pub use schema_cmd::run_schema;
27pub use scp_args::ScpAction;
28pub use sftp_args::SftpAction;
29pub use vps_action::VpsAction;
30
31use anyhow::Result;
32use clap::{ArgAction, Parser, ValueHint};
33use clap_complete::Shell;
34use std::path::PathBuf;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
38pub enum OutputFormat {
39 #[default]
41 Text,
42 Json,
44}
45
46pub(crate) fn parse_cli_char_limit(s: &str) -> Result<usize, String> {
48 let t = s.trim();
49 if t.eq_ignore_ascii_case("none") || t == "0" {
50 return Ok(0);
51 }
52 t.parse::<usize>()
53 .map_err(|e| format!("invalid char limit '{s}': {e}"))
54}
55
56#[derive(Debug, Clone, Default, clap::Args)]
60#[command(next_help_heading = "Authentication")]
61pub struct SshAuthArgs {
62 #[arg(long, conflicts_with = "password_stdin")]
64 pub password: Option<String>,
65 #[arg(long, action = ArgAction::SetTrue)]
67 pub password_stdin: bool,
68 #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
70 pub key: Option<PathBuf>,
71 #[arg(long, conflicts_with = "key_passphrase_stdin")]
73 pub key_passphrase: Option<String>,
74 #[arg(long, action = ArgAction::SetTrue)]
76 pub key_passphrase_stdin: bool,
77 #[arg(long, action = ArgAction::SetTrue)]
79 pub use_agent: bool,
80 #[arg(long, value_name = "PATH", value_hint = ValueHint::AnyPath)]
82 pub agent_socket: Option<PathBuf>,
83}
84impl SshAuthArgs {
85 #[must_use]
87 pub fn key_path_string(&self) -> Option<String> {
88 self.key.as_ref().map(|p| p.to_string_lossy().into_owned())
89 }
90}
91
92#[derive(Debug, Parser)]
94#[command(
95 name = crate::constants::APP_NAME,
96 version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
97 about = "One-shot multi-host XDG Rust CLI for LLMs to operate servers over SSH.",
98 long_about = "ssh-cli: lightweight one-shot binary (spawn→run→exit). Multi-host XDG storage without .env. \
99Password or key auth. No telemetry.",
100 after_help = "Examples:\n \
101ssh-cli vps add --name prod --host h.example --user deploy --key ~/.ssh/id_ed25519\n \
102printf '%s' \"$PASS\" | ssh-cli exec prod 'hostname' --json --password-stdin\n \
103ssh-cli scp upload prod ./a.bin /tmp/a.bin --json\n \
104ssh-cli tunnel prod 8080 127.0.0.1 80 --timeout-ms 60000 --json\n \
105ssh-cli vps export -o /tmp/hosts.toml",
106 propagate_version = true,
107 arg_required_else_help = true,
108 subcommand_required = true,
109 next_help_heading = "Global options"
110)]
111pub struct CliArgs {
112 #[arg(
116 long,
117 global = true,
118 value_name = "LOCALE",
119 value_parser = crate::locale::parse_lang_cli_arg
120 )]
121 pub lang: Option<String>,
122
123 #[arg(
128 short,
129 long,
130 global = true,
131 action = ArgAction::Count,
132 conflicts_with = "quiet"
133 )]
134 pub verbose: u8,
135
136 #[arg(
138 short,
139 long,
140 global = true,
141 action = ArgAction::SetTrue,
142 conflicts_with = "verbose"
143 )]
144 pub quiet: bool,
145
146 #[arg(
148 long,
149 global = true,
150 value_name = "DIR",
151 value_hint = ValueHint::DirPath
152 )]
153 pub config_dir: Option<PathBuf>,
154
155 #[arg(long, global = true, action = ArgAction::SetTrue)]
157 pub no_color: bool,
158
159 #[arg(long, global = true, value_enum)]
161 pub output_format: Option<OutputFormat>,
162
163 #[arg(long, global = true, action = ArgAction::SetTrue)]
168 pub json: bool,
169
170 #[arg(
176 long,
177 global = true,
178 alias = "fields",
179 value_name = "PATHS",
180 value_delimiter = ','
181 )]
182 pub select: Vec<String>,
183
184 #[arg(long, global = true, value_name = "EXPR")]
189 pub filter: Vec<String>,
190
191 #[arg(long, global = true, value_name = "N")]
193 pub limit: Option<usize>,
194
195 #[arg(long, global = true, value_name = "PATH")]
197 pub sort: Option<String>,
198
199 #[arg(long, global = true, value_name = "PATH")]
201 pub dedupe_by: Option<String>,
202
203 #[arg(long, global = true, action = ArgAction::SetTrue)]
205 pub count_only: bool,
206
207 #[arg(long, global = true, value_name = "CHARS")]
209 pub truncate_content: Option<usize>,
210
211 #[arg(long, global = true, value_name = "BYTES")]
213 pub max_output_bytes: Option<usize>,
214
215 #[arg(long, global = true, action = ArgAction::SetTrue)]
217 pub no_input: bool,
218
219 #[arg(long, global = true, action = ArgAction::SetTrue)]
227 pub dry_run: bool,
228
229 #[arg(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
231 pub disable_sudo: bool,
232
233 #[arg(long, global = true, action = ArgAction::SetTrue)]
235 pub replace_host_key: bool,
236
237 #[arg(long, global = true, action = ArgAction::SetTrue)]
239 pub allow_plaintext_secrets: bool,
240
241 #[arg(
243 long,
244 global = true,
245 value_name = "PATH",
246 value_hint = ValueHint::FilePath
247 )]
248 pub secrets_key_file: Option<PathBuf>,
249
250 #[arg(long, global = true, action = ArgAction::SetTrue)]
252 pub use_keyring: bool,
253
254 #[arg(long, global = true, value_name = "MS")]
257 pub timeout: Option<u64>,
258
259 #[arg(
264 long,
265 global = true,
266 value_name = "N",
267 value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
268 )]
269 pub max_concurrency: Option<u16>,
270
271 #[arg(long, global = true, action = ArgAction::SetTrue)]
277 pub fail_fast: bool,
278
279 #[arg(
284 long,
285 global = true,
286 value_name = "N",
287 value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
288 )]
289 pub scp_file_concurrency: Option<u16>,
290
291 #[command(subcommand)]
293 pub command: Command,
294}
295
296#[must_use]
298pub fn parse_args() -> CliArgs {
299 CliArgs::parse()
300}
301
302#[must_use]
304pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
305 local.or(global)
306}
307
308pub fn effective_timeout_ms(
313 local: Option<u64>,
314 global: Option<u64>,
315) -> Result<Option<crate::domain::TimeoutMs>, String> {
316 match effective_timeout(local, global) {
317 None => Ok(None),
318 Some(ms) => crate::domain::TimeoutMs::try_new(ms)
319 .map(Some)
320 .map_err(|e| e.to_string()),
321 }
322}
323
324pub fn parse_remote_steps(steps: Vec<String>) -> Result<Vec<crate::domain::RemoteCommand>, String> {
329 steps
330 .into_iter()
331 .map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
332 .collect()
333}
334
335#[inline]
337pub fn bootstrap_logs() {
338 crate::telemetry::bootstrap_logs();
339}
340
341#[inline]
343pub fn initialize_logs(args: &CliArgs) {
344 crate::telemetry::initialize_logs(args.verbose);
345}
346
347pub fn generate_completions(shell: Shell) -> Result<()> {
355 use clap::CommandFactory;
356 use std::io::Write;
357 let mut cmd = CliArgs::command();
358 let mut buf: Vec<u8> = Vec::new();
359 clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
360 let mut out = std::io::stdout().lock();
361 out.write_all(&buf).and_then(|()| out.flush())?;
362 Ok(())
363}
364
365#[must_use]
367pub fn command_tree_json() -> serde_json::Value {
368 use clap::CommandFactory;
369 fn walk(cmd: &clap::Command) -> serde_json::Value {
370 let name = cmd.get_name().to_string();
371 let about = cmd.get_about().map(|s| s.to_string());
372 let mut children = Vec::new();
373 for sub in cmd.get_subcommands() {
374 if sub.is_hide_set() {
375 continue;
376 }
377 children.push(walk(sub));
378 }
379 serde_json::json!({
380 "name": name,
381 "about": about,
382 "subcommands": children,
383 })
384 }
385 let root = CliArgs::command();
386 serde_json::json!({
387 "ok": true,
388 "event": "commands",
389 "bin": root.get_name(),
390 "version": env!("CARGO_PKG_VERSION"),
391 "tree": walk(&root),
392 })
393}
394
395pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
397 use clap::CommandFactory;
398 use std::io::Write;
399 let cmd = CliArgs::command();
400 let man = clap_mangen::Man::new(cmd);
401 let mut buf = Vec::new();
402 man.render(&mut buf)?;
403 if !buf.ends_with(b"\n") {
405 buf.write_all(b"\n")?;
406 }
407 Ok(buf)
408}
409
410pub(crate) fn read_stdin_if(
415 flag: bool,
416 value: Option<String>,
417) -> Result<Option<secrecy::SecretString>> {
418 if flag {
419 Ok(Some(crate::vps::read_secret_stdin()?))
427 } else {
428 Ok(value.map(secrecy::SecretString::from))
429 }
430}
431
432static NO_INPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
434
435pub fn set_no_input(value: bool) {
437 NO_INPUT.store(value, std::sync::atomic::Ordering::Relaxed);
438}
439
440#[must_use]
442pub fn is_no_input() -> bool {
443 NO_INPUT.load(std::sync::atomic::Ordering::Relaxed)
444}
445
446pub fn resolve_tunnel_mode(
459 socks5: bool,
460 remote_socket: Option<String>,
461 reverse: bool,
462 remote_host: Option<String>,
463 remote_port: Option<u16>,
464) -> Result<crate::tunnel::TunnelMode, crate::errors::SshCliError> {
465 use crate::errors::SshCliError::InvalidArgument;
466 use crate::tunnel::TunnelMode;
467
468 let positional_given = remote_host.is_some() || remote_port.is_some();
469
470 if socks5 {
471 if positional_given {
472 return Err(InvalidArgument(
473 "--socks5 chooses a destination per connection; remove REMOTE_HOST and REMOTE_PORT"
474 .to_string(),
475 ));
476 }
477 return Ok(TunnelMode::Socks5);
478 }
479
480 if let Some(socket_path) = remote_socket {
481 if positional_given {
482 return Err(InvalidArgument(
483 "--remote-socket replaces the destination; remove REMOTE_HOST and REMOTE_PORT"
484 .to_string(),
485 ));
486 }
487 return Ok(TunnelMode::StreamLocal { socket_path });
488 }
489
490 let (Some(host), Some(port)) = (remote_host, remote_port) else {
491 return Err(InvalidArgument(
492 "tunnel requires REMOTE_HOST and REMOTE_PORT unless --socks5 or --remote-socket \
493 is used"
494 .to_string(),
495 ));
496 };
497
498 if reverse {
499 return Ok(TunnelMode::Reverse {
502 remote_bind: host,
503 remote_port: port,
504 });
505 }
506
507 if port == 0 {
508 return Err(InvalidArgument(
509 "REMOTE_PORT 0 is only valid with --reverse, where the server allocates the port"
510 .to_string(),
511 ));
512 }
513 Ok(TunnelMode::Local {
514 remote_host: host,
515 remote_port: port,
516 })
517}
518
519static DRY_RUN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
521
522pub fn set_dry_run(value: bool) {
524 DRY_RUN.store(value, std::sync::atomic::Ordering::Relaxed);
525}
526
527#[must_use]
529pub fn is_dry_run() -> bool {
530 DRY_RUN.load(std::sync::atomic::Ordering::Relaxed)
531}
532
533#[must_use]
541pub fn supports_dry_run(command: &Command) -> bool {
542 use crate::cli::{SecretsAction, SftpAction, VpsAction};
543 match command {
544 Command::Vps { action } => {
545 matches!(action, VpsAction::Remove { .. } | VpsAction::Import { .. })
546 }
547 Command::Sftp { action, .. } => {
548 matches!(action, SftpAction::Rm { .. } | SftpAction::Rmdir { .. })
549 }
550 Command::Secrets { action } => matches!(
551 action,
552 SecretsAction::Init { .. } | SecretsAction::Reencrypt { .. }
553 ),
554 _ => false,
555 }
556}
557
558pub fn guard_dry_run_supported(command: &Command) -> Result<(), crate::errors::SshCliError> {
564 if !is_dry_run() || supports_dry_run(command) {
565 return Ok(());
566 }
567 Err(crate::errors::SshCliError::InvalidArgument(
568 "--dry-run is not implemented for this command; it is accepted only by \
569 `vps remove`, `vps import`, `sftp rm`, `sftp rmdir`, `secrets init` and \
570 `secrets reencrypt`"
571 .to_string(),
572 ))
573}
574
575pub fn dry_run_stop(
585 operation: &str,
586 fields: &[(&str, serde_json::Value)],
587) -> Result<bool, crate::errors::SshCliError> {
588 if !is_dry_run() {
589 return Ok(false);
590 }
591 let mut map = std::collections::BTreeMap::new();
592 map.insert("operation".to_string(), serde_json::json!(operation));
593 map.insert("dry_run".to_string(), serde_json::json!(true));
594 map.insert("executed".to_string(), serde_json::json!(false));
595 for (k, v) in fields {
596 map.insert((*k).to_string(), v.clone());
597 }
598 crate::json_wire::print_json_line(&crate::json_wire::SuccessEnvelope::new("dry-run", map))
599 .map_err(crate::errors::SshCliError::Io)?;
600 Ok(true)
601}
602
603pub(crate) fn warn_if_password_argv(args: &CliArgs) {
608 let has = match &args.command {
609 Command::Exec { auth, .. }
610 | Command::HealthCheck { auth, .. }
611 | Command::Tunnel { auth, .. } => auth.password.is_some() || auth.key_passphrase.is_some(),
612 Command::SudoExec {
613 auth,
614 sudo_password,
615 ..
616 } => auth.password.is_some() || auth.key_passphrase.is_some() || sudo_password.is_some(),
617 Command::SuExec {
618 auth, su_password, ..
619 } => auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some(),
620 Command::Scp { action } => match action {
621 ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
622 auth.password.is_some() || auth.key_passphrase.is_some()
623 }
624 },
625 Command::Sftp { action } => sftp_auth_has_argv_secret(action),
626 Command::Vps { action } => vps_action_has_argv_secret(action),
627 _ => false,
628 };
629
630 if has {
631 crate::output::print_warning(
632 "a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
633 );
634 }
635}
636
637fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
638 let auth = match action {
639 SftpAction::Upload { auth, .. }
640 | SftpAction::Download { auth, .. }
641 | SftpAction::Ls { auth, .. }
642 | SftpAction::Mkdir { auth, .. }
643 | SftpAction::Rmdir { auth, .. }
644 | SftpAction::Rm { auth, .. }
645 | SftpAction::Rename { auth, .. }
646 | SftpAction::Stat { auth, .. } => auth,
647 };
648 auth.password.is_some() || auth.key_passphrase.is_some()
649}
650
651fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
652 match action {
653 VpsAction::Add {
654 password,
655 key_passphrase,
656 sudo_password,
657 su_password,
658 ..
659 }
660 | VpsAction::Edit {
661 password,
662 key_passphrase,
663 sudo_password,
664 su_password,
665 ..
666 } => {
667 password.is_some()
668 || key_passphrase.is_some()
669 || sudo_password.is_some()
670 || su_password.is_some()
671 }
672 _ => false,
673 }
674}
675
676#[must_use]
680pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
681 if let Some(f) = explicit {
682 return f;
683 }
684 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
685 OutputFormat::Json
686 } else {
687 OutputFormat::Text
688 }
689}
690
691pub fn resolve_format_from_cli(
696 json: bool,
697 explicit: Option<OutputFormat>,
698) -> Result<OutputFormat, crate::errors::SshCliError> {
699 if json {
702 return Ok(OutputFormat::Json);
703 }
704 Ok(resolve_format(explicit))
705}
706
707mod dispatch;
708
709pub use dispatch::{dispatch, dispatch_impl};
710
711#[cfg(test)]
712mod tests;