#![forbid(unsafe_code)]
mod commands;
mod path_parse;
mod schema_cmd;
mod scp_args;
mod sftp_args;
mod vps_action;
pub use commands::{
Command, LocaleAction, SecretsAction, TlsAcmeAccountAction, TlsAcmeAction, TlsAction,
TlsMtlsAction,
};
pub(crate) use path_parse::{parse_exec_target, parse_hosts_list, parse_scp_target, ScpPathPlan};
pub use schema_cmd::run_schema;
pub use scp_args::ScpAction;
pub use sftp_args::SftpAction;
pub use vps_action::VpsAction;
use anyhow::Result;
use clap::{ArgAction, Parser, ValueHint};
use clap_complete::Shell;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
pub(crate) fn parse_cli_char_limit(s: &str) -> Result<usize, String> {
let t = s.trim();
if t.eq_ignore_ascii_case("none") || t == "0" {
return Ok(0);
}
t.parse::<usize>()
.map_err(|e| format!("invalid char limit '{s}': {e}"))
}
#[derive(Debug, Clone, Default, clap::Args)]
#[command(next_help_heading = "Authentication")]
pub struct SshAuthArgs {
#[arg(long, conflicts_with = "password_stdin")]
pub password: Option<String>,
#[arg(long, action = ArgAction::SetTrue)]
pub password_stdin: bool,
#[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath)]
pub key: Option<PathBuf>,
#[arg(long, conflicts_with = "key_passphrase_stdin")]
pub key_passphrase: Option<String>,
#[arg(long, action = ArgAction::SetTrue)]
pub key_passphrase_stdin: bool,
#[arg(long, action = ArgAction::SetTrue)]
pub use_agent: bool,
#[arg(long, value_name = "PATH", value_hint = ValueHint::AnyPath)]
pub agent_socket: Option<PathBuf>,
}
impl SshAuthArgs {
#[must_use]
pub fn key_path_string(&self) -> Option<String> {
self.key.as_ref().map(|p| p.to_string_lossy().into_owned())
}
}
#[derive(Debug, Parser)]
#[command(
name = crate::constants::APP_NAME,
version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
about = "One-shot multi-host XDG Rust CLI for LLMs to operate servers over SSH.",
long_about = "ssh-cli: lightweight one-shot binary (spawn→run→exit). Multi-host XDG storage without .env. \
Password or key auth. No telemetry.",
after_help = "Examples:\n \
ssh-cli vps add --name prod --host h.example --user deploy --key ~/.ssh/id_ed25519\n \
printf '%s' \"$PASS\" | ssh-cli exec prod 'hostname' --json --password-stdin\n \
ssh-cli scp upload prod ./a.bin /tmp/a.bin --json\n \
ssh-cli tunnel prod 8080 127.0.0.1 80 --timeout-ms 60000 --json\n \
ssh-cli vps export -o /tmp/hosts.toml",
propagate_version = true,
arg_required_else_help = true,
subcommand_required = true,
next_help_heading = "Global options"
)]
pub struct CliArgs {
#[arg(
long,
global = true,
value_name = "LOCALE",
value_parser = crate::locale::parse_lang_cli_arg
)]
pub lang: Option<String>,
#[arg(
short,
long,
global = true,
action = ArgAction::Count,
conflicts_with = "quiet"
)]
pub verbose: u8,
#[arg(
short,
long,
global = true,
action = ArgAction::SetTrue,
conflicts_with = "verbose"
)]
pub quiet: bool,
#[arg(
long,
global = true,
value_name = "DIR",
value_hint = ValueHint::DirPath
)]
pub config_dir: Option<PathBuf>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_color: bool,
#[arg(long, global = true, value_enum)]
pub output_format: Option<OutputFormat>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub json: bool,
#[arg(
long,
global = true,
alias = "fields",
value_name = "PATHS",
value_delimiter = ','
)]
pub select: Vec<String>,
#[arg(long, global = true, value_name = "EXPR")]
pub filter: Vec<String>,
#[arg(long, global = true, value_name = "N")]
pub limit: Option<usize>,
#[arg(long, global = true, value_name = "PATH")]
pub sort: Option<String>,
#[arg(long, global = true, value_name = "PATH")]
pub dedupe_by: Option<String>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub count_only: bool,
#[arg(long, global = true, value_name = "CHARS")]
pub truncate_content: Option<usize>,
#[arg(long, global = true, value_name = "BYTES")]
pub max_output_bytes: Option<usize>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_input: bool,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub dry_run: bool,
#[arg(long, global = true, alias = "disableSudo", action = ArgAction::SetTrue)]
pub disable_sudo: bool,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub replace_host_key: bool,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub allow_plaintext_secrets: bool,
#[arg(
long,
global = true,
value_name = "PATH",
value_hint = ValueHint::FilePath
)]
pub secrets_key_file: Option<PathBuf>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub use_keyring: bool,
#[arg(long, global = true, value_name = "MS")]
pub timeout: Option<u64>,
#[arg(
long,
global = true,
value_name = "N",
value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
)]
pub max_concurrency: Option<u16>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub fail_fast: bool,
#[arg(
long,
global = true,
value_name = "N",
value_parser = clap::value_parser!(u16).range(1..=(crate::constants::MAX_CONCURRENCY as i64))
)]
pub scp_file_concurrency: Option<u16>,
#[command(subcommand)]
pub command: Command,
}
#[must_use]
pub fn parse_args() -> CliArgs {
CliArgs::parse()
}
#[must_use]
pub fn effective_timeout(local: Option<u64>, global: Option<u64>) -> Option<u64> {
local.or(global)
}
pub fn effective_timeout_ms(
local: Option<u64>,
global: Option<u64>,
) -> Result<Option<crate::domain::TimeoutMs>, String> {
match effective_timeout(local, global) {
None => Ok(None),
Some(ms) => crate::domain::TimeoutMs::try_new(ms)
.map(Some)
.map_err(|e| e.to_string()),
}
}
pub fn parse_remote_steps(steps: Vec<String>) -> Result<Vec<crate::domain::RemoteCommand>, String> {
steps
.into_iter()
.map(|s| crate::domain::RemoteCommand::try_new(s).map_err(|e| e.to_string()))
.collect()
}
#[inline]
pub fn bootstrap_logs() {
crate::telemetry::bootstrap_logs();
}
#[inline]
pub fn initialize_logs(args: &CliArgs) {
crate::telemetry::initialize_logs(args.verbose);
}
pub fn generate_completions(shell: Shell) -> Result<()> {
use clap::CommandFactory;
use std::io::Write;
let mut cmd = CliArgs::command();
let mut buf: Vec<u8> = Vec::new();
clap_complete::generate(shell, &mut cmd, crate::constants::APP_NAME, &mut buf);
let mut out = std::io::stdout().lock();
out.write_all(&buf).and_then(|()| out.flush())?;
Ok(())
}
#[must_use]
pub fn command_tree_json() -> serde_json::Value {
use clap::CommandFactory;
fn walk(cmd: &clap::Command) -> serde_json::Value {
let name = cmd.get_name().to_string();
let about = cmd.get_about().map(|s| s.to_string());
let mut children = Vec::new();
for sub in cmd.get_subcommands() {
if sub.is_hide_set() {
continue;
}
children.push(walk(sub));
}
serde_json::json!({
"name": name,
"about": about,
"subcommands": children,
})
}
let root = CliArgs::command();
serde_json::json!({
"ok": true,
"event": "commands",
"bin": root.get_name(),
"version": env!("CARGO_PKG_VERSION"),
"tree": walk(&root),
})
}
pub fn render_manpage() -> Result<Vec<u8>, std::io::Error> {
use clap::CommandFactory;
use std::io::Write;
let cmd = CliArgs::command();
let man = clap_mangen::Man::new(cmd);
let mut buf = Vec::new();
man.render(&mut buf)?;
if !buf.ends_with(b"\n") {
buf.write_all(b"\n")?;
}
Ok(buf)
}
pub(crate) fn read_stdin_if(
flag: bool,
value: Option<String>,
) -> Result<Option<secrecy::SecretString>> {
if flag {
Ok(Some(crate::vps::read_secret_stdin()?))
} else {
Ok(value.map(secrecy::SecretString::from))
}
}
static NO_INPUT: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_no_input(value: bool) {
NO_INPUT.store(value, std::sync::atomic::Ordering::Relaxed);
}
#[must_use]
pub fn is_no_input() -> bool {
NO_INPUT.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn resolve_tunnel_mode(
socks5: bool,
remote_socket: Option<String>,
reverse: bool,
remote_host: Option<String>,
remote_port: Option<u16>,
) -> Result<crate::tunnel::TunnelMode, crate::errors::SshCliError> {
use crate::errors::SshCliError::InvalidArgument;
use crate::tunnel::TunnelMode;
let positional_given = remote_host.is_some() || remote_port.is_some();
if socks5 {
if positional_given {
return Err(InvalidArgument(
"--socks5 chooses a destination per connection; remove REMOTE_HOST and REMOTE_PORT"
.to_string(),
));
}
return Ok(TunnelMode::Socks5);
}
if let Some(socket_path) = remote_socket {
if positional_given {
return Err(InvalidArgument(
"--remote-socket replaces the destination; remove REMOTE_HOST and REMOTE_PORT"
.to_string(),
));
}
return Ok(TunnelMode::StreamLocal { socket_path });
}
let (Some(host), Some(port)) = (remote_host, remote_port) else {
return Err(InvalidArgument(
"tunnel requires REMOTE_HOST and REMOTE_PORT unless --socks5 or --remote-socket \
is used"
.to_string(),
));
};
if reverse {
return Ok(TunnelMode::Reverse {
remote_bind: host,
remote_port: port,
});
}
if port == 0 {
return Err(InvalidArgument(
"REMOTE_PORT 0 is only valid with --reverse, where the server allocates the port"
.to_string(),
));
}
Ok(TunnelMode::Local {
remote_host: host,
remote_port: port,
})
}
static DRY_RUN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn set_dry_run(value: bool) {
DRY_RUN.store(value, std::sync::atomic::Ordering::Relaxed);
}
#[must_use]
pub fn is_dry_run() -> bool {
DRY_RUN.load(std::sync::atomic::Ordering::Relaxed)
}
#[must_use]
pub fn supports_dry_run(command: &Command) -> bool {
use crate::cli::{SecretsAction, SftpAction, VpsAction};
match command {
Command::Vps { action } => {
matches!(action, VpsAction::Remove { .. } | VpsAction::Import { .. })
}
Command::Sftp { action, .. } => {
matches!(action, SftpAction::Rm { .. } | SftpAction::Rmdir { .. })
}
Command::Secrets { action } => matches!(
action,
SecretsAction::Init { .. } | SecretsAction::Reencrypt { .. }
),
_ => false,
}
}
pub fn guard_dry_run_supported(command: &Command) -> Result<(), crate::errors::SshCliError> {
if !is_dry_run() || supports_dry_run(command) {
return Ok(());
}
Err(crate::errors::SshCliError::InvalidArgument(
"--dry-run is not implemented for this command; it is accepted only by \
`vps remove`, `vps import`, `sftp rm`, `sftp rmdir`, `secrets init` and \
`secrets reencrypt`"
.to_string(),
))
}
pub fn dry_run_stop(
operation: &str,
fields: &[(&str, serde_json::Value)],
) -> Result<bool, crate::errors::SshCliError> {
if !is_dry_run() {
return Ok(false);
}
let mut map = std::collections::BTreeMap::new();
map.insert("operation".to_string(), serde_json::json!(operation));
map.insert("dry_run".to_string(), serde_json::json!(true));
map.insert("executed".to_string(), serde_json::json!(false));
for (k, v) in fields {
map.insert((*k).to_string(), v.clone());
}
crate::json_wire::print_json_line(&crate::json_wire::SuccessEnvelope::new("dry-run", map))
.map_err(crate::errors::SshCliError::Io)?;
Ok(true)
}
pub(crate) fn warn_if_password_argv(args: &CliArgs) {
let has = match &args.command {
Command::Exec { auth, .. }
| Command::HealthCheck { auth, .. }
| Command::Tunnel { auth, .. } => auth.password.is_some() || auth.key_passphrase.is_some(),
Command::SudoExec {
auth,
sudo_password,
..
} => auth.password.is_some() || auth.key_passphrase.is_some() || sudo_password.is_some(),
Command::SuExec {
auth, su_password, ..
} => auth.password.is_some() || auth.key_passphrase.is_some() || su_password.is_some(),
Command::Scp { action } => match action {
ScpAction::Upload { auth, .. } | ScpAction::Download { auth, .. } => {
auth.password.is_some() || auth.key_passphrase.is_some()
}
},
Command::Sftp { action } => sftp_auth_has_argv_secret(action),
Command::Vps { action } => vps_action_has_argv_secret(action),
_ => false,
};
if has {
crate::output::print_warning(
"a password-like value was passed on the command line (visible in process lists); prefer --*-stdin",
);
}
}
fn sftp_auth_has_argv_secret(action: &SftpAction) -> bool {
let auth = match action {
SftpAction::Upload { auth, .. }
| SftpAction::Download { auth, .. }
| SftpAction::Ls { auth, .. }
| SftpAction::Mkdir { auth, .. }
| SftpAction::Rmdir { auth, .. }
| SftpAction::Rm { auth, .. }
| SftpAction::Rename { auth, .. }
| SftpAction::Stat { auth, .. } => auth,
};
auth.password.is_some() || auth.key_passphrase.is_some()
}
fn vps_action_has_argv_secret(action: &VpsAction) -> bool {
match action {
VpsAction::Add {
password,
key_passphrase,
sudo_password,
su_password,
..
}
| VpsAction::Edit {
password,
key_passphrase,
sudo_password,
su_password,
..
} => {
password.is_some()
|| key_passphrase.is_some()
|| sudo_password.is_some()
|| su_password.is_some()
}
_ => false,
}
}
#[must_use]
pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
if let Some(f) = explicit {
return f;
}
if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
OutputFormat::Json
} else {
OutputFormat::Text
}
}
pub fn resolve_format_from_cli(
json: bool,
explicit: Option<OutputFormat>,
) -> Result<OutputFormat, crate::errors::SshCliError> {
if json {
return Ok(OutputFormat::Json);
}
Ok(resolve_format(explicit))
}
mod dispatch;
pub use dispatch::{dispatch, dispatch_impl};
#[cfg(test)]
mod tests;