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(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
172 pub disable_sudo: bool,
173
174 #[arg(long, global = true, action = ArgAction::SetTrue)]
176 pub replace_host_key: bool,
177
178 #[arg(long, global = true, action = ArgAction::SetTrue)]
180 pub allow_plaintext_secrets: bool,
181
182 #[arg(
184 long,
185 global = true,
186 value_name = "PATH",
187 value_hint = ValueHint::FilePath
188 )]
189 pub secrets_key_file: Option<PathBuf>,
190
191 #[arg(long, global = true, action = ArgAction::SetTrue)]
193 pub use_keyring: bool,
194
195 #[arg(long, global = true, value_name = "MS")]
198 pub timeout: Option<u64>,
199
200 #[arg(
205 long,
206 global = true,
207 value_name = "N",
208 value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
209 )]
210 pub max_concurrency: Option<u16>,
211
212 #[arg(long, global = true, action = ArgAction::SetTrue)]
218 pub fail_fast: bool,
219
220 #[arg(
225 long,
226 global = true,
227 value_name = "N",
228 value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
229 )]
230 pub scp_file_concurrency: Option<u16>,
231
232 #[command(subcommand)]
234 pub command: Command,
235}
236
237#[must_use]
239pub fn parse_args() -> CliArgs {
240 CliArgs::parse()
241}
242
243#[must_use]
245pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
246 local.or(global)
247}
248
249pub fn effective_timeout_ms(
254 local: Option<u64>,
255 global: Option<u64>,
256) -> Result<Option<crate::domain::TimeoutMs>, String> {
257 match effective_timeout(local, global) {
258 None => Ok(None),
259 Some(ms) => crate::domain::TimeoutMs::try_new(ms)
260 .map(Some)
261 .map_err(|e| e.to_string()),
262 }
263}
264
265pub fn parse_remote_steps(steps: Vec<String>) -> Result<Vec<crate::domain::RemoteCommand>, String> {
270 steps
271 .into_iter()
272 .map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
273 .collect()
274}
275
276#[inline]
278pub fn bootstrap_logs() {
279 crate::telemetry::bootstrap_logs();
280}
281
282#[inline]
284pub fn initialize_logs(args: &CliArgs) {
285 crate::telemetry::initialize_logs(args.verbose);
286}
287
288pub fn generate_completions(shell: Shell) -> Result<()> {
296 use clap::CommandFactory;
297 use std::io::Write;
298 let mut cmd = CliArgs::command();
299 let mut buf: Vec<u8> = Vec::new();
300 clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
301 let mut out = std::io::stdout().lock();
302 out.write_all(&buf).and_then(|()| out.flush())?;
303 Ok(())
304}
305
306#[must_use]
308pub fn command_tree_json() -> serde_json::Value {
309 use clap::CommandFactory;
310 fn walk(cmd: &clap::Command) -> serde_json::Value {
311 let name = cmd.get_name().to_string();
312 let about = cmd.get_about().map(|s| s.to_string());
313 let mut children = Vec::new();
314 for sub in cmd.get_subcommands() {
315 if sub.is_hide_set() {
316 continue;
317 }
318 children.push(walk(sub));
319 }
320 serde_json::json!({
321 "name": name,
322 "about": about,
323 "subcommands": children,
324 })
325 }
326 let root = CliArgs::command();
327 serde_json::json!({
328 "ok": true,
329 "event": "commands",
330 "bin": root.get_name(),
331 "version": env!("CARGO_PKG_VERSION"),
332 "tree": walk(&root),
333 })
334}
335
336pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
338 use clap::CommandFactory;
339 use std::io::Write;
340 let cmd = CliArgs::command();
341 let man = clap_mangen::Man::new(cmd);
342 let mut buf = Vec::new();
343 man.render(&mut buf)?;
344 if !buf.ends_with(b"\n") {
346 buf.write_all(b"\n")?;
347 }
348 Ok(buf)
349}
350
351pub(crate) fn read_stdin_if(
356 flag: bool,
357 value: Option<String>,
358) -> Result<Option<secrecy::SecretString>> {
359 if flag {
360 Ok(Some(crate::vps::read_secret_stdin()?))
361 } else {
362 Ok(value.map(secrecy::SecretString::from))
363 }
364}
365
366pub(crate) fn warn_if_password_argv(args: &CliArgs) {
371 let has = match &args.command {
372 Command::Exec { auth, .. }
373 | Command::HealthCheck { auth, .. }
374 | Command::Tunnel { auth, .. } => auth.password.is_some() || auth.key_passphrase.is_some(),
375 Command::SudoExec {
376 auth,
377 sudo_password,
378 ..
379 } => auth.password.is_some() || auth.key_passphrase.is_some() || sudo_password.is_some(),
380 Command::SuExec {
381 auth, su_password, ..
382 } => auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some(),
383 Command::Scp { action } => match action {
384 ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
385 auth.password.is_some() || auth.key_passphrase.is_some()
386 }
387 },
388 Command::Sftp { action } => sftp_auth_has_argv_secret(action),
389 Command::Vps { action } => vps_action_has_argv_secret(action),
390 _ => false,
391 };
392
393 if has {
394 crate::output::print_warning(
395 "a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
396 );
397 }
398}
399
400fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
401 let auth = match action {
402 SftpAction::Upload { auth, .. }
403 | SftpAction::Download { auth, .. }
404 | SftpAction::Ls { auth, .. }
405 | SftpAction::Mkdir { auth, .. }
406 | SftpAction::Rmdir { auth, .. }
407 | SftpAction::Rm { auth, .. }
408 | SftpAction::Rename { auth, .. }
409 | SftpAction::Stat { auth, .. } => auth,
410 };
411 auth.password.is_some() || auth.key_passphrase.is_some()
412}
413
414fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
415 match action {
416 VpsAction::Add {
417 password,
418 key_passphrase,
419 sudo_password,
420 su_password,
421 ..
422 }
423 | VpsAction::Edit {
424 password,
425 key_passphrase,
426 sudo_password,
427 su_password,
428 ..
429 } => {
430 password.is_some()
431 || key_passphrase.is_some()
432 || sudo_password.is_some()
433 || su_password.is_some()
434 }
435 _ => false,
436 }
437}
438
439#[must_use]
443pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
444 if let Some(f) = explicit {
445 return f;
446 }
447 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
448 OutputFormat::Json
449 } else {
450 OutputFormat::Text
451 }
452}
453
454pub fn resolve_format_from_cli(
459 json: bool,
460 explicit: Option<OutputFormat>,
461) -> Result<OutputFormat, crate::errors::SshCliError> {
462 if json {
465 return Ok(OutputFormat::Json);
466 }
467 Ok(resolve_format(explicit))
468}
469
470mod dispatch;
471
472pub use dispatch::{dispatch, dispatch_impl};
473
474#[cfg(test)]
475mod tests;