#![forbid(unsafe_code)]
mod scp_args;
mod sftp_args;
mod vps_action;
mod commands;
mod path_parse;
mod schema_cmd;
pub use scp_args::ScpAction;
pub use sftp_args::SftpAction;
pub use vps_action::VpsAction;
pub use commands::{
Command, LocaleAction, SecretsAction, TlsAcmeAccountAction, TlsAcmeAction, TlsAction,
TlsMtlsAction,
};
pub use schema_cmd::run_schema;
pub(crate) use path_parse::{parse_exec_target, parse_hosts_list, parse_scp_target, ScpPathPlan};
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::SetTrue,
conflicts_with = "quiet"
)]
pub verbose: bool,
#[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 = "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))
}
}
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;