1use anyhow::Result;
13use clap::{Parser, Subcommand};
14use clap_complete::Shell;
15use std::path::PathBuf;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
19pub enum OutputFormat {
20 #[default]
22 Text,
23 Json,
25}
26
27#[derive(Debug, Parser)]
29#[command(
30 name = "ssh-cli",
31 version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
32 about = "One-shot multi-host XDG Rust CLI for LLMs to operate servers over SSH.",
33 long_about = "ssh-cli: lightweight one-shot binary (spawn→run→exit). Multi-host XDG storage without .env. \
34Password or key auth. No telemetry."
35)]
36pub struct CliArgs {
37 #[arg(long, global = true, value_name = "LOCALE")]
39 pub lang: Option<String>,
40
41 #[arg(short, long, global = true)]
43 pub verbose: bool,
44
45 #[arg(short, long, global = true)]
47 pub quiet: bool,
48
49 #[arg(long, global = true, value_name = "DIR")]
51 pub config_dir: Option<PathBuf>,
52
53 #[arg(long, global = true)]
55 pub no_color: bool,
56
57 #[arg(long, global = true, value_enum)]
59 pub output_format: Option<OutputFormat>,
60
61 #[arg(long, global = true, alias = "disableSudo")]
63 pub disable_sudo: bool,
64
65 #[arg(long, global = true)]
67 pub replace_host_key: bool,
68
69 #[arg(long, global = true)]
71 pub allow_plaintext_secrets: bool,
72
73 #[arg(long, global = true, value_name = "PATH")]
75 pub secrets_key_file: Option<PathBuf>,
76
77 #[arg(long, global = true)]
79 pub use_keyring: bool,
80
81 #[command(subcommand)]
83 pub command: Command,
84}
85
86#[derive(Debug, Subcommand)]
88pub enum Command {
89 Vps {
91 #[command(subcommand)]
93 action: VpsAction,
94 },
95
96 Connect {
98 name: String,
100 },
101
102 Exec {
104 vps_name: String,
106 command: String,
108 #[arg(long)]
110 json: bool,
111 #[arg(long, conflicts_with = "password_stdin")]
113 password: Option<String>,
114 #[arg(long)]
116 password_stdin: bool,
117 #[arg(long)]
119 key: Option<String>,
120 #[arg(long, conflicts_with = "key_passphrase_stdin")]
122 key_passphrase: Option<String>,
123 #[arg(long)]
125 key_passphrase_stdin: bool,
126 #[arg(long)]
128 timeout: Option<u64>,
129 #[arg(long)]
131 description: Option<String>,
132 },
133
134 SudoExec {
136 vps_name: String,
138 command: String,
140 #[arg(long)]
142 json: bool,
143 #[arg(long, conflicts_with = "password_stdin")]
145 password: Option<String>,
146 #[arg(long)]
148 password_stdin: bool,
149 #[arg(
151 long,
152 alias = "sudoPassword",
153 alias = "sudo_password",
154 conflicts_with = "sudo_password_stdin"
155 )]
156 sudo_password: Option<String>,
157 #[arg(long)]
159 sudo_password_stdin: bool,
160 #[arg(long)]
162 key: Option<String>,
163 #[arg(long, conflicts_with = "key_passphrase_stdin")]
165 key_passphrase: Option<String>,
166 #[arg(long)]
168 key_passphrase_stdin: bool,
169 #[arg(long)]
171 timeout: Option<u64>,
172 #[arg(long)]
174 description: Option<String>,
175 },
176
177 SuExec {
179 vps_name: String,
181 command: String,
183 #[arg(long)]
185 json: bool,
186 #[arg(long, conflicts_with = "password_stdin")]
188 password: Option<String>,
189 #[arg(long)]
191 password_stdin: bool,
192 #[arg(
194 long,
195 alias = "suPassword",
196 alias = "su_password",
197 conflicts_with = "su_password_stdin"
198 )]
199 su_password: Option<String>,
200 #[arg(long)]
202 su_password_stdin: bool,
203 #[arg(long)]
205 key: Option<String>,
206 #[arg(long, conflicts_with = "key_passphrase_stdin")]
208 key_passphrase: Option<String>,
209 #[arg(long)]
211 key_passphrase_stdin: bool,
212 #[arg(long)]
214 timeout: Option<u64>,
215 #[arg(long)]
217 description: Option<String>,
218 },
219
220 Scp {
222 #[command(subcommand)]
224 action: ScpAction,
225 },
226
227 Tunnel {
229 vps_name: String,
231 local_port: u16,
233 remote_host: String,
235 remote_port: u16,
237 #[arg(long)]
239 timeout_ms: u64,
240 #[arg(long, conflicts_with = "password_stdin")]
242 password: Option<String>,
243 #[arg(long)]
245 password_stdin: bool,
246 #[arg(long)]
248 key: Option<String>,
249 #[arg(long, conflicts_with = "key_passphrase_stdin")]
251 key_passphrase: Option<String>,
252 #[arg(long)]
254 key_passphrase_stdin: bool,
255 #[arg(long)]
257 json: bool,
258 #[arg(long, default_value = "127.0.0.1")]
260 bind: String,
261 },
262
263 HealthCheck {
265 vps_name: Option<String>,
267 #[arg(long)]
269 json: bool,
270 #[arg(long, conflicts_with = "password_stdin")]
272 password: Option<String>,
273 #[arg(long)]
275 password_stdin: bool,
276 #[arg(long)]
278 key: Option<String>,
279 #[arg(long, conflicts_with = "key_passphrase_stdin")]
281 key_passphrase: Option<String>,
282 #[arg(long)]
284 key_passphrase_stdin: bool,
285 #[arg(long)]
287 timeout: Option<u64>,
288 },
289
290 Secrets {
292 #[command(subcommand)]
294 action: SecretsAction,
295 },
296
297 Completions {
299 #[arg(value_enum)]
301 shell: Shell,
302 },
303}
304
305#[derive(Debug, Subcommand)]
307pub enum VpsAction {
308 Add {
310 #[arg(long)]
312 name: String,
313 #[arg(long)]
315 host: String,
316 #[arg(long, default_value_t = 22)]
318 port: u16,
319 #[arg(long)]
321 user: String,
322 #[arg(long, conflicts_with = "password_stdin")]
324 password: Option<String>,
325 #[arg(long)]
327 password_stdin: bool,
328 #[arg(long)]
330 key: Option<String>,
331 #[arg(long)]
333 key_passphrase: Option<String>,
334 #[arg(long, default_value_t = 60_000)]
336 timeout: u64,
337 #[arg(long)]
339 max_command_chars: Option<String>,
340 #[arg(long)]
342 max_output_chars: Option<String>,
343 #[arg(long, alias = "maxChars")]
345 max_chars: Option<String>,
346 #[arg(
348 long,
349 alias = "sudoPassword",
350 alias = "sudo_password",
351 conflicts_with = "sudo_password_stdin"
352 )]
353 sudo_password: Option<String>,
354 #[arg(long)]
356 sudo_password_stdin: bool,
357 #[arg(
359 long,
360 alias = "suPassword",
361 alias = "su_password",
362 conflicts_with = "su_password_stdin"
363 )]
364 su_password: Option<String>,
365 #[arg(long)]
367 su_password_stdin: bool,
368 #[arg(long, default_value_t = false)]
370 disable_sudo: bool,
371 #[arg(long)]
373 check: bool,
374 },
375
376 List {
378 #[arg(long)]
380 json: bool,
381 },
382
383 Remove {
385 name: String,
387 },
388
389 Edit {
391 name: String,
393 #[arg(long)]
395 host: Option<String>,
396 #[arg(long)]
398 port: Option<u16>,
399 #[arg(long)]
401 user: Option<String>,
402 #[arg(long, conflicts_with = "password_stdin")]
404 password: Option<String>,
405 #[arg(long)]
407 password_stdin: bool,
408 #[arg(long)]
410 key: Option<String>,
411 #[arg(long)]
413 key_passphrase: Option<String>,
414 #[arg(long)]
416 timeout: Option<u64>,
417 #[arg(long)]
419 max_command_chars: Option<String>,
420 #[arg(long)]
422 max_output_chars: Option<String>,
423 #[arg(long, alias = "maxChars")]
425 max_chars: Option<String>,
426 #[arg(
428 long,
429 alias = "sudoPassword",
430 alias = "sudo_password",
431 conflicts_with = "sudo_password_stdin"
432 )]
433 sudo_password: Option<String>,
434 #[arg(long)]
436 sudo_password_stdin: bool,
437 #[arg(
439 long,
440 alias = "suPassword",
441 alias = "su_password",
442 conflicts_with = "su_password_stdin"
443 )]
444 su_password: Option<String>,
445 #[arg(long)]
447 su_password_stdin: bool,
448 #[arg(long)]
450 disable_sudo: Option<bool>,
451 },
452
453 Show {
455 name: String,
457 #[arg(long)]
459 json: bool,
460 },
461
462 Path,
464
465 Doctor {
467 #[arg(long)]
469 json: bool,
470 },
471
472 Export {
474 #[arg(long)]
476 include_secrets: bool,
477 #[arg(long, short)]
479 output: Option<String>,
480 #[arg(long)]
483 json: bool,
484 #[arg(long)]
486 i_understand_secrets_on_stdout: bool,
487 },
488
489 Import {
491 #[arg(long)]
493 file: PathBuf,
494 #[arg(long)]
496 allow_incomplete: bool,
497 },
498}
499
500#[derive(Debug, Subcommand)]
502pub enum ScpAction {
503 Upload {
505 vps_name: String,
507 local: PathBuf,
509 remote: PathBuf,
511 #[arg(long, conflicts_with = "password_stdin")]
513 password: Option<String>,
514 #[arg(long)]
516 password_stdin: bool,
517 #[arg(long)]
519 key: Option<String>,
520 #[arg(long, conflicts_with = "key_passphrase_stdin")]
522 key_passphrase: Option<String>,
523 #[arg(long)]
525 key_passphrase_stdin: bool,
526 #[arg(long)]
528 timeout: Option<u64>,
529 #[arg(long)]
531 json: bool,
532 },
533
534 Download {
536 vps_name: String,
538 remote: PathBuf,
540 local: PathBuf,
542 #[arg(long, conflicts_with = "password_stdin")]
544 password: Option<String>,
545 #[arg(long)]
547 password_stdin: bool,
548 #[arg(long)]
550 key: Option<String>,
551 #[arg(long, conflicts_with = "key_passphrase_stdin")]
553 key_passphrase: Option<String>,
554 #[arg(long)]
556 key_passphrase_stdin: bool,
557 #[arg(long)]
559 timeout: Option<u64>,
560 #[arg(long)]
562 json: bool,
563 },
564}
565
566#[derive(Debug, Subcommand)]
568pub enum SecretsAction {
569 Status {
571 #[arg(long)]
573 json: bool,
574 },
575 Init {
577 #[arg(long)]
579 keyring: bool,
580 #[arg(long)]
582 force: bool,
583 #[arg(long)]
585 json: bool,
586 },
587 Reencrypt {
589 #[arg(long)]
591 json: bool,
592 },
593}
594
595#[must_use]
597pub fn parse_args() -> CliArgs {
598 CliArgs::parse()
599}
600
601pub fn initialize_logs(args: &CliArgs) {
606 use tracing_subscriber::{fmt, EnvFilter};
607
608 let filter = if std::env::var("RUST_LOG").is_ok() {
609 EnvFilter::from_default_env()
610 } else if args.verbose {
611 EnvFilter::new("debug")
612 } else {
613 let _ = args.quiet;
615 EnvFilter::new("error")
616 };
617
618 let _ = fmt()
619 .with_env_filter(filter)
620 .with_writer(std::io::stderr)
621 .with_target(false)
622 .with_ansi(false)
623 .try_init();
624}
625
626pub fn generate_completions(shell: Shell) {
630 use clap::CommandFactory;
631 use std::io::Write;
632 let mut cmd = CliArgs::command();
633 let mut buf: Vec<u8> = Vec::new();
634 clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
635 let mut out = std::io::stdout().lock();
636 if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
637 if e.kind() == std::io::ErrorKind::BrokenPipe {
638 return;
639 }
640 let _ = writeln!(std::io::stderr(), "failed to write completions: {e}");
642 }
643}
644
645fn read_stdin_if(flag: bool, value: Option<String>) -> Result<Option<String>> {
646 if flag {
647 Ok(Some(crate::vps::read_secret_stdin()?))
648 } else {
649 Ok(value)
650 }
651}
652
653fn warn_if_password_argv(args: &CliArgs) {
654 let s = format!("{:?}", args.command);
656 let sensitive = ["password:", "key_passphrase:", "sudo_password:", "su_password:"];
657 let has = sensitive.iter().any(|k| s.contains(k) && s.contains("Some("));
659 if has {
660 eprintln!(
661 "warning: a password-like value was passed on the command line (visible in process lists); prefer --*-stdin"
662 );
663 }
664}
665
666#[must_use]
668pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
669 if let Some(f) = explicit {
670 return f;
671 }
672 if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
674 return OutputFormat::Text;
675 }
676 if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
677 OutputFormat::Json
678 } else {
679 OutputFormat::Text
680 }
681}
682
683pub async fn dispatch(args: CliArgs) -> Result<()> {
685 let config_override = args.config_dir.clone();
686 crate::secrets::set_config_dir(config_override.clone());
688 crate::secrets::set_runtime_flags(
689 args.allow_plaintext_secrets,
690 args.secrets_key_file.clone(),
691 args.use_keyring,
692 );
693 let formato = resolve_format(args.output_format);
694 crate::output::set_quiet(args.quiet);
696 crate::output::set_json_errors(formato == OutputFormat::Json);
697 let disable_sudo = args.disable_sudo;
698 let replace_host_key = args.replace_host_key;
699
700 warn_if_password_argv(&args);
702
703 match args.command {
704 Command::Vps { action } => {
705 crate::vps::run_vps_command(action, config_override, formato).await
706 }
707 Command::Connect { name } => {
708 crate::vps::run_connect(&name, config_override, formato).await
709 },
710 Command::Exec {
711 vps_name,
712 command,
713 json,
714 password,
715 password_stdin,
716 key,
717 key_passphrase,
718 key_passphrase_stdin,
719 timeout,
720 description,
721 } => {
722 let password = read_stdin_if(password_stdin, password)?;
723 let key_passphrase = read_stdin_if(key_passphrase_stdin, key_passphrase)?;
724 let opts = crate::vps::ExecOptions {
725 password,
726 key,
727 key_passphrase,
728 timeout,
729 description,
730 replace_host_key,
731 disable_sudo,
732 ..Default::default()
733 };
734 crate::vps::run_exec(&vps_name, &command, config_override, formato, json, opts)
735 .await
736 }
737 Command::SudoExec {
738 vps_name,
739 command,
740 json,
741 password,
742 password_stdin,
743 sudo_password,
744 sudo_password_stdin,
745 key,
746 key_passphrase,
747 key_passphrase_stdin,
748 timeout,
749 description,
750 } => {
751 let password = read_stdin_if(password_stdin, password)?;
752 let sudo_password = read_stdin_if(sudo_password_stdin, sudo_password)?;
753 let key_passphrase = read_stdin_if(key_passphrase_stdin, key_passphrase)?;
754 let opts = crate::vps::ExecOptions {
755 password,
756 sudo_password,
757 key,
758 key_passphrase,
759 timeout,
760 description,
761 replace_host_key,
762 disable_sudo,
763 ..Default::default()
764 };
765 crate::vps::run_sudo_exec(
766 &vps_name,
767 &command,
768 config_override,
769 formato,
770 json,
771 opts,
772 )
773 .await
774 }
775 Command::SuExec {
776 vps_name,
777 command,
778 json,
779 password,
780 password_stdin,
781 su_password,
782 su_password_stdin,
783 key,
784 key_passphrase,
785 key_passphrase_stdin,
786 timeout,
787 description,
788 } => {
789 let password = read_stdin_if(password_stdin, password)?;
790 let su_password = read_stdin_if(su_password_stdin, su_password)?;
791 let key_passphrase = read_stdin_if(key_passphrase_stdin, key_passphrase)?;
792 let opts = crate::vps::ExecOptions {
793 password,
794 su_password,
795 key,
796 key_passphrase,
797 timeout,
798 description,
799 replace_host_key,
800 disable_sudo,
801 ..Default::default()
802 };
803 crate::vps::run_su_exec(&vps_name, &command, config_override, formato, json, opts)
804 .await
805 }
806 Command::Scp { action } => {
807 let (
808 password,
809 password_stdin,
810 key,
811 key_passphrase,
812 key_passphrase_stdin,
813 timeout,
814 json_local,
815 ) = match &action {
816 ScpAction::Upload {
817 password,
818 password_stdin,
819 key,
820 key_passphrase,
821 key_passphrase_stdin,
822 timeout,
823 json,
824 ..
825 }
826 | ScpAction::Download {
827 password,
828 password_stdin,
829 key,
830 key_passphrase,
831 key_passphrase_stdin,
832 timeout,
833 json,
834 ..
835 } => (
836 password.clone(),
837 *password_stdin,
838 key.clone(),
839 key_passphrase.clone(),
840 *key_passphrase_stdin,
841 *timeout,
842 *json,
843 ),
844 };
845 let password = read_stdin_if(password_stdin, password)?;
846 let key_passphrase = read_stdin_if(key_passphrase_stdin, key_passphrase)?;
847 let json_efetivo = json_local || formato == OutputFormat::Json;
849 if json_efetivo {
850 crate::output::set_json_errors(true);
851 }
852 crate::scp::run_scp(
853 action,
854 config_override,
855 crate::scp::ScpOptions {
856 password,
857 key,
858 key_passphrase,
859 timeout,
860 replace_host_key,
861 json: json_efetivo,
862 },
863 )
864 .await
865 }
866 Command::Tunnel {
867 vps_name,
868 local_port,
869 remote_host,
870 remote_port,
871 timeout_ms,
872 password,
873 password_stdin,
874 key,
875 key_passphrase,
876 key_passphrase_stdin,
877 json,
878 bind,
879 } => {
880 let json_efetivo = json || formato == OutputFormat::Json;
882 if json_efetivo {
883 crate::output::set_json_errors(true);
884 }
885 let password = read_stdin_if(password_stdin, password)?;
887 let key_passphrase = read_stdin_if(key_passphrase_stdin, key_passphrase)?;
888 crate::tunnel::run_tunnel(
889 &vps_name,
890 local_port,
891 &remote_host,
892 remote_port,
893 config_override,
894 password,
895 key,
896 key_passphrase,
897 timeout_ms,
898 replace_host_key,
899 json_efetivo,
900 &bind,
901 )
902 .await
903 }
904 Command::HealthCheck {
905 vps_name,
906 json,
907 password,
908 password_stdin,
909 key,
910 key_passphrase,
911 key_passphrase_stdin,
912 timeout,
913 } => {
914 let password = read_stdin_if(password_stdin, password)?;
916 let key_passphrase = read_stdin_if(key_passphrase_stdin, key_passphrase)?;
917 crate::vps::run_health_check(
918 vps_name.as_deref(),
919 config_override,
920 formato,
921 json,
922 password,
923 timeout,
924 key,
925 key_passphrase,
926 replace_host_key,
927 )
928 .await
929 }
930 Command::Secrets { action } => {
931 crate::vps::run_secrets_command(action, config_override, formato).await
932 }
933 Command::Completions { shell } => {
934 generate_completions(shell);
935 Ok(())
936 }
937 }
938}
939
940#[cfg(test)]
941mod tests {
942 use super::*;
943 use clap::Parser;
944
945 #[test]
946 fn parser_understands_tunnel_with_timeout() {
947 let args = CliArgs::try_parse_from([
948 "ssh-cli",
949 "tunnel",
950 "vps-a",
951 "8080",
952 "127.0.0.1",
953 "5432",
954 "--timeout-ms",
955 "5000",
956 "--json",
957 ])
958 .expect("tunnel");
959 match args.command {
960 Command::Tunnel {
961 timeout_ms,
962 local_port,
963 json,
964 ..
965 } => {
966 assert_eq!(timeout_ms, 5000);
967 assert_eq!(local_port, 8080);
968 assert!(json);
969 }
970 _ => panic!("esperado tunnel"),
971 }
972 }
973
974 #[test]
975 fn parser_vps_add_key() {
976 let args = CliArgs::try_parse_from([
977 "ssh-cli",
978 "vps",
979 "add",
980 "--name",
981 "x",
982 "--host",
983 "h",
984 "--user",
985 "u",
986 "--key",
987 "/tmp/id_ed25519",
988 ])
989 .expect("add key");
990 match args.command {
991 Command::Vps {
992 action: VpsAction::Add { key, password, .. },
993 } => {
994 assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
995 assert!(password.is_none());
996 }
997 _ => panic!("esperado add"),
998 }
999 }
1000
1001 #[test]
1002 fn parser_sudo_exec_description() {
1003 let args = CliArgs::try_parse_from([
1004 "ssh-cli",
1005 "sudo-exec",
1006 "v",
1007 "id",
1008 "--description",
1009 "who am i",
1010 ])
1011 .unwrap();
1012 match args.command {
1013 Command::SudoExec { description, .. } => {
1014 assert_eq!(description.as_deref(), Some("who am i"));
1015 }
1016 _ => panic!("sudo-exec"),
1017 }
1018 }
1019
1020 #[test]
1021 fn parser_su_exec() {
1022 let args = CliArgs::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
1023 assert!(matches!(args.command, Command::SuExec { .. }));
1024 }
1025
1026 #[test]
1027 fn parser_disable_sudo_global() {
1028 let args =
1029 CliArgs::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
1030 assert!(args.disable_sudo);
1031 }
1032
1033 #[test]
1034 fn parser_doctor() {
1035 let args = CliArgs::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
1036 match args.command {
1037 Command::Vps {
1038 action: VpsAction::Doctor { json },
1039 } => assert!(json),
1040 _ => panic!("doctor"),
1041 }
1042 }
1043}