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