use clap::Parser;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
mod command;
mod config;
mod util;
use config::Config;
use crate::command::Commands;
use crate::config::Profile;
use crate::util::{
check_dependencies, copy_ssh_key_manual, ensure_ssh_key, remove_alias, setup_multiplex,
ssh_target, validate_alias,
};
#[derive(Parser)]
#[command(name = "qs")]
#[command(about = "Quick SSH - Dead simple, zero-friction SSH wrapper")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
fn main() {
let cli = Cli::parse();
let mut config = Config::load().unwrap_or_else(|err| {
eprintln!("Error loading config: {err}");
eprintln!("Please fix the config file or remove it to start fresh.");
std::process::exit(1);
});
match cli.command {
Commands::Check => {
println!("Checking dependencies...\n");
match check_dependencies() {
Ok(_) => {
println!("✓ All required tools are installed");
println!("✓ ssh");
println!("✓ ssh-keygen");
println!("✓ rsync");
}
Err(msg) => {
eprintln!("{msg}");
std::process::exit(1);
}
}
}
Commands::Init => {
if let Err(msg) = check_dependencies() {
eprintln!("{msg}");
eprintln!("Run 'qs check' after installing missing tools");
std::process::exit(1);
}
let key_path = ensure_ssh_key();
println!("✓ SSH key ready at {}", key_path.display());
println!("✓ Public key at {}.pub", key_path.display());
println!("\nNext: Add a host with 'qs add <alias> -host <host> -user <user>'");
}
Commands::Add {
alias,
host,
user,
skip_key,
is_default,
overwrite,
} => {
if let Err(msg) = check_dependencies() {
eprintln!("{msg}");
std::process::exit(1);
}
if let Err(error) = validate_alias(&alias) {
eprintln!("Error: {error}");
std::process::exit(1);
}
if let Some(existing_host) = config.profiles.get(&alias) {
if !overwrite {
eprintln!("Error: Alias '{alias}' already exists");
eprintln!(" Host: {}", existing_host.host);
eprintln!(" User: {}", existing_host.user);
eprintln!("\nUse --overwrite to replace the existing alias");
std::process::exit(1);
}
}
let profile = Profile { host, user };
if !skip_key {
copy_ssh_key_manual(&profile);
}
config.profiles.insert(alias.clone(), profile);
if is_default || config.default.is_none() {
config.default = Some(alias.clone());
println!("✓ Set {alias} as default");
}
if let Err(e) = config.save() {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
println!("✓ Added alias: {alias}");
}
Commands::Remove { alias } => match remove_alias(&mut config, &alias) {
Ok(messages) => {
for message in messages {
println!("{message}");
}
if let Err(e) = config.save() {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
}
Err(error) => {
eprintln!("{error}");
eprintln!("\nAvailable aliases:");
for alias_name in config.profiles.keys() {
eprintln!(" - {alias_name}");
}
std::process::exit(1);
}
},
Commands::List => {
if config.profiles.is_empty() {
println!(
"No hosts configured. Use 'qs add <alias> -host <host> -user <user>' to add one."
);
return;
}
println!("Configured hosts:\n");
for (alias, profile) in &config.profiles {
let default = if Some(alias) == config.default.as_ref() {
" [default]"
} else {
""
};
println!(" {}{}: {}@{}", alias, default, profile.user, profile.host);
}
}
Commands::Connect { alias } => {
let profile = config.get_profile(&alias).unwrap_or_else(|err| {
eprintln!("{err}");
std::process::exit(1);
});
let mut cmd = Command::new("ssh");
cmd.args(setup_multiplex());
cmd.arg(ssh_target(profile));
cmd.status().ok();
}
Commands::Send { source, dest } => {
let (alias_name, remote_path) = if dest.contains(':') {
let parts: Vec<_> = dest.splitn(2, ':').collect();
(parts[0], parts[1].to_string())
} else {
("default", dest.clone())
};
let profile = config.get_profile(alias_name).unwrap_or_else(|err| {
eprintln!("{err}");
std::process::exit(1);
});
let absolute_source = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&source);
if !absolute_source.exists() {
eprintln!("Error: Source file '{source}' does not exist");
eprintln!("Looking for: {}", absolute_source.display());
std::process::exit(1);
}
println!("Sending {source} → {alias_name}:{remote_path}");
let mut cmd = Command::new("rsync");
cmd.arg("-az");
cmd.arg("--progress");
cmd.arg("-e");
cmd.arg(format!("ssh {}", setup_multiplex().join(" ")));
cmd.arg(absolute_source.to_string_lossy().to_string());
cmd.arg(format!("{}:{}", ssh_target(profile), remote_path));
if !cmd.status().map(|s| s.success()).unwrap_or(false) {
eprintln!("\n✗ Transfer failed");
std::process::exit(1);
}
}
Commands::Get { source, dest } => {
let (alias_name, remote_path) = if source.contains(':') {
let parts: Vec<_> = source.splitn(2, ':').collect();
(parts[0], parts[1].to_string())
} else {
("default", source.clone())
};
let profile = config.get_profile(alias_name).unwrap_or_else(|err| {
eprintln!("{err}");
std::process::exit(1);
});
let absolute_dest = std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(&dest);
println!("Getting {alias_name}:{remote_path} → {dest}");
let mut cmd = Command::new("rsync");
cmd.arg("-az");
cmd.arg("--progress");
cmd.arg("-e");
cmd.arg(format!("ssh {}", setup_multiplex().join(" ")));
cmd.arg(format!("{}:{}", ssh_target(profile), remote_path));
cmd.arg(absolute_dest.to_string_lossy().to_string());
if !cmd.status().map(|s| s.success()).unwrap_or(false) {
eprintln!("\n✗ Transfer failed");
std::process::exit(1);
}
}
Commands::Exec {
alias: alias_name,
cmd,
} => {
if cmd.is_empty() {
eprintln!("No command specified");
std::process::exit(1);
}
let profile = config.get_profile(&alias_name).unwrap_or_else(|err| {
eprintln!("{err}");
std::process::exit(1);
});
let mut ssh_cmd = Command::new("ssh");
ssh_cmd.args(setup_multiplex());
ssh_cmd.arg(ssh_target(profile));
ssh_cmd.arg(cmd.join(" "));
ssh_cmd.status().ok();
}
Commands::Status { alias } => {
let profile = config.get_profile(&alias).unwrap_or_else(|err| {
eprintln!("{err}");
std::process::exit(1);
});
let display_alias = if alias == "default" {
match config.default.as_ref() {
Some(actual_alias) => format!("{actual_alias} [default]"),
None => alias,
}
} else {
alias
};
print!("Checking connection to {display_alias}... ");
io::stdout().flush().unwrap();
let mut cmd = Command::new("ssh");
cmd.args(setup_multiplex());
cmd.arg("-O");
cmd.arg("check");
cmd.arg(ssh_target(profile));
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
match cmd.status() {
Ok(status) if status.success() => println!("✓ Active"),
_ => println!("✗ No active connection"),
}
}
Commands::SetDefault { alias } => {
if !config.profiles.contains_key(&alias) {
eprintln!("Alias '{alias}' not found");
eprintln!("\nAvailable aliases:");
for alias_name in config.profiles.keys() {
eprintln!(" - {alias_name}");
}
std::process::exit(1);
}
config.default = Some(alias.clone());
if let Err(e) = config.save() {
eprintln!("Error saving config: {e}");
std::process::exit(1);
}
println!("✓ Set {alias} as default");
}
}
}