1#![forbid(unsafe_code)]
4mod scp_args;
15mod sftp_args;
16mod vps_action;
17mod commands;
18mod path_parse;
19mod schema_cmd;
20
21pub use scp_args::ScpAction;
22pub use sftp_args::SftpAction;
23pub use vps_action::VpsAction;
24pub use commands::{
25 Command, LocaleAction, SecretsAction, TlsAcmeAccountAction, TlsAcmeAction, TlsAction,
26 TlsMtlsAction,
27};
28pub use schema_cmd::run_schema;
29pub(crate) use path_parse::{parse_exec_target, parse_hosts_list, parse_scp_target, ScpPathPlan};
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
89 .as_ref()
90 .map(|p| p.to_string_lossy().into_owned())
91 }
92}
93
94#[derive(Debug, Parser)]
96#[command(
97 name = crate::constants::APP_NAME,
98 version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
99 about = "One-shot multi-host XDG Rust CLI for LLMs to operate servers over SSH.",
100 long_about = "ssh-cli: lightweight one-shot binary (spawn→run→exit). Multi-host XDG storage without .env. \
101Password or key auth. No telemetry.",
102 after_help = "Examples:\n \
103ssh-cli vps add --name prod --host h.example --user deploy --key ~/.ssh/id_ed25519\n \
104printf '%s' \"$PASS\" | ssh-cli exec prod 'hostname' --json --password-stdin\n \
105ssh-cli scp upload prod ./a.bin /tmp/a.bin --json\n \
106ssh-cli tunnel prod 8080 127.0.0.1 80 --timeout-ms 60000 --json\n \
107ssh-cli vps export -o /tmp/hosts.toml",
108 propagate_version = true,
109 arg_required_else_help = true,
110 subcommand_required = true,
111 next_help_heading = "Global options"
112)]
113pub struct CliArgs {
114 #[arg(
118 long,
119 global = true,
120 value_name = "LOCALE",
121 value_parser = crate::locale::parse_lang_cli_arg
122 )]
123 pub lang: Option<String>,
124
125 #[arg(
127 short,
128 long,
129 global = true,
130 action = ArgAction::SetTrue,
131 conflicts_with = "quiet"
132 )]
133 pub verbose: bool,
134
135 #[arg(
137 short,
138 long,
139 global = true,
140 action = ArgAction::SetTrue,
141 conflicts_with = "verbose"
142 )]
143 pub quiet: bool,
144
145 #[arg(
147 long,
148 global = true,
149 value_name = "DIR",
150 value_hint = ValueHint::DirPath
151 )]
152 pub config_dir: Option<PathBuf>,
153
154 #[arg(long, global = true, action = ArgAction::SetTrue)]
156 pub no_color: bool,
157
158 #[arg(long, global = true, value_enum)]
160 pub output_format: Option<OutputFormat>,
161
162 #[arg(long, global = true, action = ArgAction::SetTrue)]
167 pub json: bool,
168
169 #[arg(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
171 pub disable_sudo: bool,
172
173 #[arg(long, global = true, action = ArgAction::SetTrue)]
175 pub replace_host_key: bool,
176
177 #[arg(long, global = true, action = ArgAction::SetTrue)]
179 pub allow_plaintext_secrets: bool,
180
181 #[arg(
183 long,
184 global = true,
185 value_name = "PATH",
186 value_hint = ValueHint::FilePath
187 )]
188 pub secrets_key_file: Option<PathBuf>,
189
190 #[arg(long, global = true, action = ArgAction::SetTrue)]
192 pub use_keyring: bool,
193
194 #[arg(long, global = true, value_name = "MS")]
197 pub timeout: Option<u64>,
198
199 #[arg(
204 long,
205 global = true,
206 value_name = "N",
207 value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
208 )]
209 pub max_concurrency: Option<u16>,
210
211 #[arg(long, global = true, action = ArgAction::SetTrue)]
217 pub fail_fast: bool,
218
219 #[arg(
224 long,
225 global = true,
226 value_name = "N",
227 value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
228 )]
229 pub scp_file_concurrency: Option<u16>,
230
231 #[command(subcommand)]
233 pub command: Command,
234}
235
236#[must_use]
238pub fn parse_args() -> CliArgs {
239 CliArgs::parse()
240}
241
242#[must_use]
244pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
245 local.or(global)
246}
247
248pub fn effective_timeout_ms(
253 local: Option<u64>,
254 global: Option<u64>,
255) -> Result<Option<crate::domain::TimeoutMs>, String> {
256 match effective_timeout(local, global) {
257 None => Ok(None),
258 Some(ms) => crate::domain::TimeoutMs::try_new(ms)
259 .map(Some)
260 .map_err(|e| e.to_string()),
261 }
262}
263
264pub fn parse_remote_steps(
269 steps: Vec<String>,
270) -> Result<Vec<crate::domain::RemoteCommand>, String> {
271 steps
272 .into_iter()
273 .map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
274 .collect()
275}
276
277#[inline]
279pub fn bootstrap_logs() {
280 crate::telemetry::bootstrap_logs();
281}
282
283#[inline]
285pub fn initialize_logs(args: &CliArgs) {
286 crate::telemetry::initialize_logs(args.verbose);
287}
288
289pub fn generate_completions(shell: Shell) -> Result<()> {
297 use clap::CommandFactory;
298 use std::io::Write;
299 let mut cmd = CliArgs::command();
300 let mut buf: Vec<u8> = Vec::new();
301 clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
302 let mut out = std::io::stdout().lock();
303 out.write_all(&buf).and_then(|()| out.flush())?;
304 Ok(())
305}
306
307#[must_use]
309pub fn command_tree_json() -> serde_json::Value {
310 use clap::CommandFactory;
311 fn walk(cmd: &clap::Command) -> serde_json::Value {
312 let name = cmd.get_name().to_string();
313 let about = cmd.get_about().map(|s| s.to_string());
314 let mut children = Vec::new();
315 for sub in cmd.get_subcommands() {
316 if sub.is_hide_set() {
317 continue;
318 }
319 children.push(walk(sub));
320 }
321 serde_json::json!({
322 "name": name,
323 "about": about,
324 "subcommands": children,
325 })
326 }
327 let root = CliArgs::command();
328 serde_json::json!({
329 "ok": true,
330 "event": "commands",
331 "bin": root.get_name(),
332 "version": env!("CARGO_PKG_VERSION"),
333 "tree": walk(&root),
334 })
335}
336
337pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
339 use clap::CommandFactory;
340 use std::io::Write;
341 let cmd = CliArgs::command();
342 let man = clap_mangen::Man::new(cmd);
343 let mut buf = Vec::new();
344 man.render(&mut buf)?;
345 if !buf.ends_with(b"\n") {
347 buf.write_all(b"\n")?;
348 }
349 Ok(buf)
350}
351
352pub(crate) fn read_stdin_if(
357 flag: bool,
358 value: Option<String>,
359) -> Result<Option<secrecy::SecretString>> {
360 if flag {
361 Ok(Some(crate::vps::read_secret_stdin()?))
362 } else {
363 Ok(value.map(secrecy::SecretString::from))
364 }
365}
366
367pub(crate) fn warn_if_password_argv(args: &CliArgs) {
372 let has = match &args.command {
373 Command::Exec { auth, .. }
374 | Command::HealthCheck { auth, .. }
375 | Command::Tunnel { auth, .. } => {
376 auth.password.is_some() || auth.key_passphrase.is_some()
377 }
378 Command::SudoExec {
379 auth,
380 sudo_password,
381 ..
382 } => {
383 auth.password.is_some()
384 || auth.key_passphrase.is_some()
385 || sudo_password.is_some()
386 }
387 Command::SuExec {
388 auth,
389 su_password,
390 ..
391 } => {
392 auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some()
393 }
394 Command::Scp { action } => match action {
395 ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
396 auth.password.is_some() || auth.key_passphrase.is_some()
397 }
398 },
399 Command::Sftp { action } => sftp_auth_has_argv_secret(action),
400 Command::Vps { action } => vps_action_has_argv_secret(action),
401 _ => false,
402 };
403
404 if has {
405 crate::output::print_warning(
406 "a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
407 );
408 }
409}
410
411fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
412 let auth = match action {
413 SftpAction::Upload { auth, .. }
414 | SftpAction::Download { auth, .. }
415 | SftpAction::Ls { auth, .. }
416 | SftpAction::Mkdir { auth, .. }
417 | SftpAction::Rmdir { auth, .. }
418 | SftpAction::Rm { auth, .. }
419 | SftpAction::Rename { auth, .. }
420 | SftpAction::Stat { auth, .. } => auth,
421 };
422 auth.password.is_some() || auth.key_passphrase.is_some()
423}
424
425fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
426 match action {
427 VpsAction::Add {
428 password,
429 key_passphrase,
430 sudo_password,
431 su_password,
432 ..
433 }
434 | VpsAction::Edit {
435 password,
436 key_passphrase,
437 sudo_password,
438 su_password,
439 ..
440 } => {
441 password.is_some()
442 || key_passphrase.is_some()
443 || sudo_password.is_some()
444 || su_password.is_some()
445 }
446 _ => false,
447 }
448}
449
450#[must_use]
454pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
455 if let Some(f) = explicit {
456 return f;
457 }
458 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
459 OutputFormat::Json
460 } else {
461 OutputFormat::Text
462 }
463}
464
465pub fn resolve_format_from_cli(
470 json: bool,
471 explicit: Option<OutputFormat>,
472) -> Result<OutputFormat, crate::errors::SshCliError> {
473 if json {
476 return Ok(OutputFormat::Json);
477 }
478 Ok(resolve_format(explicit))
479}
480
481
482mod dispatch;
483
484pub use dispatch::{dispatch, dispatch_impl};
485
486#[cfg(test)]
487mod tests;