Skip to main content

ssh_cli/
cli.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! CLI argument definitions via `clap` derive and dispatcher.
3//!
4//! 1. CRUD de VPS — `vps add|list|remove|edit|show|path|doctor|export|import`
5//! 2. `connect` — writes sibling `active` file (not a TOML field)
6//! 3. One-shot execution — `exec|sudo-exec|su-exec|scp|tunnel|health-check`
7//! 4. `secrets` — primary-key status/init/reencrypt (cifragem at-rest default)
8//! 5. Completions
9//!
10//! ZERO `.env` em runtime. ZERO telemetria. Ciclo one-shot: nascer → dispatch → morrer.
11
12use anyhow::Result;
13use clap::{Parser, Subcommand};
14use clap_complete::Shell;
15use std::path::PathBuf;
16
17/// Output format supported by the CLI.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
19pub enum OutputFormat {
20    /// Human-readable text (default).
21    #[default]
22    Text,
23    /// Structured JSON.
24    Json,
25}
26
27/// Global ssh-cli arguments.
28#[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    /// Forces the CLI language (e.g. `pt-BR`, `en-US`).
38    #[arg(long, global = true, value_name = "LOCALE")]
39    pub lang: Option<String>,
40
41    /// Increases log verbosity on stderr.
42    #[arg(short, long, global = true)]
43    pub verbose: bool,
44
45    /// Suppresses non-JSON output (quiet mode).
46    #[arg(short, long, global = true)]
47    pub quiet: bool,
48
49    /// Configuration directory override (useful for tests).
50    #[arg(long, global = true, value_name = "DIR")]
51    pub config_dir: Option<PathBuf>,
52
53    /// Disables colored output.
54    #[arg(long, global = true)]
55    pub no_color: bool,
56
57    /// Global output format (text, json). If omitted: JSON when stdout is not a TTY.
58    #[arg(long, global = true, value_enum)]
59    pub output_format: Option<OutputFormat>,
60
61    /// Disables sudo-exec/su-exec for this invocation (alias --disableSudo).
62    #[arg(long, global = true, alias = "disableSudo")]
63    pub disable_sudo: bool,
64
65    /// Replaces a diverging host key in TOFU known_hosts.
66    #[arg(long, global = true)]
67    pub replace_host_key: bool,
68
69    /// Allow plaintext secrets at rest (no auto `secrets.key`). Prefer for tests only.
70    #[arg(long, global = true)]
71    pub allow_plaintext_secrets: bool,
72
73    /// Path to a 64-hex primary-key file (overrides env / XDG secrets.key for this one-shot).
74    #[arg(long, global = true, value_name = "PATH")]
75    pub secrets_key_file: Option<PathBuf>,
76
77    /// Prefer OS keyring for the primary key (deprecated env: SSH_CLI_USE_KEYRING).
78    #[arg(long, global = true)]
79    pub use_keyring: bool,
80
81    /// Subcommand to run.
82    #[command(subcommand)]
83    pub command: Command,
84}
85
86/// Top-level subcommands.
87#[derive(Debug, Subcommand)]
88pub enum Command {
89    /// Manages registered VPS hosts.
90    Vps {
91        /// Specific VPS CRUD action.
92        #[command(subcommand)]
93        action: VpsAction,
94    },
95
96    /// Sets the active VPS (writes sibling `active` file in the config directory).
97    Connect {
98        /// Name of the VPS previously added via `vps add`.
99        name: String,
100    },
101
102    /// Runs a command on the VPS over SSH (stdout/stderr captured).
103    Exec {
104        /// VPS name.
105        vps_name: String,
106        /// Shell command to run.
107        command: String,
108        /// JSON output.
109        #[arg(long)]
110        json: bool,
111        /// SSH password override.
112        #[arg(long, conflicts_with = "password_stdin")]
113        password: Option<String>,
114        /// Reads the SSH password from stdin.
115        #[arg(long)]
116        password_stdin: bool,
117        /// Private key path override.
118        #[arg(long)]
119        key: Option<String>,
120        /// Key passphrase (runtime).
121        #[arg(long, conflicts_with = "key_passphrase_stdin")]
122        key_passphrase: Option<String>,
123        /// Reads the key passphrase from stdin.
124        #[arg(long)]
125        key_passphrase_stdin: bool,
126        /// Timeout override in milliseconds.
127        #[arg(long)]
128        timeout: Option<u64>,
129        /// Shell comment appended for audit trails.
130        #[arg(long)]
131        description: Option<String>,
132    },
133
134    /// Runs a command with `sudo` (safe `sh -c` packing).
135    SudoExec {
136        /// VPS name.
137        vps_name: String,
138        /// Shell command.
139        command: String,
140        /// JSON output.
141        #[arg(long)]
142        json: bool,
143        /// SSH password override.
144        #[arg(long, conflicts_with = "password_stdin")]
145        password: Option<String>,
146        /// Reads the SSH password from stdin.
147        #[arg(long)]
148        password_stdin: bool,
149        /// Sudo password override.
150        #[arg(
151            long,
152            alias = "sudoPassword",
153            alias = "sudo_password",
154            conflicts_with = "sudo_password_stdin"
155        )]
156        sudo_password: Option<String>,
157        /// Reads the sudo password from stdin.
158        #[arg(long)]
159        sudo_password_stdin: bool,
160        /// Key path override.
161        #[arg(long)]
162        key: Option<String>,
163        /// Key passphrase (runtime).
164        #[arg(long, conflicts_with = "key_passphrase_stdin")]
165        key_passphrase: Option<String>,
166        /// Reads the key passphrase from stdin.
167        #[arg(long)]
168        key_passphrase_stdin: bool,
169        /// Timeout override in milliseconds.
170        #[arg(long)]
171        timeout: Option<u64>,
172        /// Shell comment appended for audit.
173        #[arg(long)]
174        description: Option<String>,
175    },
176
177    /// Runs a command with one-shot `su -` elevation.
178    SuExec {
179        /// VPS name.
180        vps_name: String,
181        /// Shell command.
182        command: String,
183        /// JSON output.
184        #[arg(long)]
185        json: bool,
186        /// SSH password override.
187        #[arg(long, conflicts_with = "password_stdin")]
188        password: Option<String>,
189        /// Reads the SSH password from stdin (GAP-SSH-CLI-001).
190        #[arg(long)]
191        password_stdin: bool,
192        /// Su password override.
193        #[arg(
194            long,
195            alias = "suPassword",
196            alias = "su_password",
197            conflicts_with = "su_password_stdin"
198        )]
199        su_password: Option<String>,
200        /// Reads the su password from stdin.
201        #[arg(long)]
202        su_password_stdin: bool,
203        /// Key path override.
204        #[arg(long)]
205        key: Option<String>,
206        /// Key passphrase (runtime).
207        #[arg(long, conflicts_with = "key_passphrase_stdin")]
208        key_passphrase: Option<String>,
209        /// Reads the key passphrase from stdin.
210        #[arg(long)]
211        key_passphrase_stdin: bool,
212        /// Timeout override.
213        #[arg(long)]
214        timeout: Option<u64>,
215        /// Shell comment appended for audit.
216        #[arg(long)]
217        description: Option<String>,
218    },
219
220    /// SCP file transfer (upload/download).
221    Scp {
222        /// Specific SCP action.
223        #[command(subcommand)]
224        action: ScpAction,
225    },
226
227    /// SSH tunnel with mandatory deadline (bounded one-shot).
228    Tunnel {
229        /// VPS name.
230        vps_name: String,
231        /// Local port.
232        local_port: u16,
233        /// Remote host.
234        remote_host: String,
235        /// Remote port.
236        remote_port: u16,
237        /// Mandatory tunnel timeout in milliseconds.
238        #[arg(long)]
239        timeout_ms: u64,
240        /// SSH password override.
241        #[arg(long, conflicts_with = "password_stdin")]
242        password: Option<String>,
243        /// Reads the SSH password from stdin (GAP-SSH-CLI-005).
244        #[arg(long)]
245        password_stdin: bool,
246        /// Private key path override.
247        #[arg(long)]
248        key: Option<String>,
249        /// Key passphrase.
250        #[arg(long, conflicts_with = "key_passphrase_stdin")]
251        key_passphrase: Option<String>,
252        /// Reads the key passphrase from stdin (GAP-SSH-CLI-005).
253        #[arg(long)]
254        key_passphrase_stdin: bool,
255        /// Agent-first JSON output when the local listener is up (GAP-SSH-IO-008).
256        #[arg(long)]
257        json: bool,
258        /// Local bind address (default 127.0.0.1 loopback for security).
259        #[arg(long, default_value = "127.0.0.1")]
260        bind: String,
261    },
262
263    /// Checks SSH connectivity to a VPS.
264    HealthCheck {
265        /// VPS name (uses active if omitted).
266        vps_name: Option<String>,
267        /// JSON output (GAP-SSH-IO-002).
268        #[arg(long)]
269        json: bool,
270        /// SSH password override.
271        #[arg(long, conflicts_with = "password_stdin")]
272        password: Option<String>,
273        /// Reads the SSH password from stdin (GAP-SSH-CLI-006).
274        #[arg(long)]
275        password_stdin: bool,
276        /// Private key path override (GAP-SSH-CLI-006).
277        #[arg(long)]
278        key: Option<String>,
279        /// Key passphrase.
280        #[arg(long, conflicts_with = "key_passphrase_stdin")]
281        key_passphrase: Option<String>,
282        /// Reads the key passphrase from stdin (GAP-SSH-CLI-006).
283        #[arg(long)]
284        key_passphrase_stdin: bool,
285        /// SSH timeout override in milliseconds (GAP-SSH-CLI-004).
286        #[arg(long)]
287        timeout: Option<u64>,
288    },
289
290    /// Manages the primary key and at-rest secret encryption (one-shot).
291    Secrets {
292        /// Secrets action.
293        #[command(subcommand)]
294        action: SecretsAction,
295    },
296
297    /// Generates shell completions.
298    Completions {
299        /// Target shell.
300        #[arg(value_enum)]
301        shell: Shell,
302    },
303}
304
305/// Actions of the `vps` subcommand.
306#[derive(Debug, Subcommand)]
307pub enum VpsAction {
308    /// Adds a new VPS to the registry.
309    Add {
310        /// Unique VPS name.
311        #[arg(long)]
312        name: String,
313        /// Hostname or IP.
314        #[arg(long)]
315        host: String,
316        /// SSH port.
317        #[arg(long, default_value_t = 22)]
318        port: u16,
319        /// SSH username.
320        #[arg(long)]
321        user: String,
322        /// SSH password.
323        #[arg(long, conflicts_with = "password_stdin")]
324        password: Option<String>,
325        /// Reads the password from stdin.
326        #[arg(long)]
327        password_stdin: bool,
328        /// OpenSSH private key path.
329        #[arg(long)]
330        key: Option<String>,
331        /// Key passphrase.
332        #[arg(long)]
333        key_passphrase: Option<String>,
334        /// Timeout in milliseconds (default 60000).
335        #[arg(long, default_value_t = 60_000)]
336        timeout: u64,
337        /// Command character limit (input). Legacy alias: maxChars.
338        #[arg(long)]
339        max_command_chars: Option<String>,
340        /// Output character limit.
341        #[arg(long)]
342        max_output_chars: Option<String>,
343        /// Legacy alias: maps to max_command_chars.
344        #[arg(long, alias = "maxChars")]
345        max_chars: Option<String>,
346        /// Password for `sudo`.
347        #[arg(
348            long,
349            alias = "sudoPassword",
350            alias = "sudo_password",
351            conflicts_with = "sudo_password_stdin"
352        )]
353        sudo_password: Option<String>,
354        /// Reads the sudo password from stdin.
355        #[arg(long)]
356        sudo_password_stdin: bool,
357        /// Password for `su -`.
358        #[arg(
359            long,
360            alias = "suPassword",
361            alias = "su_password",
362            conflicts_with = "su_password_stdin"
363        )]
364        su_password: Option<String>,
365        /// Reads the su password from stdin.
366        #[arg(long)]
367        su_password_stdin: bool,
368        /// Disables sudo/su on this host.
369        #[arg(long, default_value_t = false)]
370        disable_sudo: bool,
371        /// Runs health-check after add.
372        #[arg(long)]
373        check: bool,
374    },
375
376    /// Lists all VPS hosts (passwords masked).
377    List {
378        /// JSON output.
379        #[arg(long)]
380        json: bool,
381    },
382
383    /// Removes a VPS from the registry.
384    Remove {
385        /// VPS name to remove.
386        name: String,
387    },
388
389    /// Edits fields of an existing VPS.
390    Edit {
391        /// VPS name to edit.
392        name: String,
393        /// New hostname/IP.
394        #[arg(long)]
395        host: Option<String>,
396        /// New SSH port.
397        #[arg(long)]
398        port: Option<u16>,
399        /// New username.
400        #[arg(long)]
401        user: Option<String>,
402        /// New password.
403        #[arg(long, conflicts_with = "password_stdin")]
404        password: Option<String>,
405        /// Reads the password from stdin.
406        #[arg(long)]
407        password_stdin: bool,
408        /// New private key path.
409        #[arg(long)]
410        key: Option<String>,
411        /// New key passphrase.
412        #[arg(long)]
413        key_passphrase: Option<String>,
414        /// New timeout.
415        #[arg(long)]
416        timeout: Option<u64>,
417        /// New max command chars.
418        #[arg(long)]
419        max_command_chars: Option<String>,
420        /// New max output chars.
421        #[arg(long)]
422        max_output_chars: Option<String>,
423        /// Legacy alias maxChars → command.
424        #[arg(long, alias = "maxChars")]
425        max_chars: Option<String>,
426        /// New sudo password.
427        #[arg(
428            long,
429            alias = "sudoPassword",
430            alias = "sudo_password",
431            conflicts_with = "sudo_password_stdin"
432        )]
433        sudo_password: Option<String>,
434        /// Reads the sudo password from stdin.
435        #[arg(long)]
436        sudo_password_stdin: bool,
437        /// New su password.
438        #[arg(
439            long,
440            alias = "suPassword",
441            alias = "su_password",
442            conflicts_with = "su_password_stdin"
443        )]
444        su_password: Option<String>,
445        /// Reads the su password from stdin.
446        #[arg(long)]
447        su_password_stdin: bool,
448        /// Sets disable_sudo.
449        #[arg(long)]
450        disable_sudo: Option<bool>,
451    },
452
453    /// Shows VPS details (passwords masked).
454    Show {
455        /// VPS name.
456        name: String,
457        /// JSON output.
458        #[arg(long)]
459        json: bool,
460    },
461
462    /// Shows the configuration file path.
463    Path,
464
465    /// Diagnostics for XDG layers / path / schema.
466    Doctor {
467        /// JSON output.
468        #[arg(long)]
469        json: bool,
470    },
471
472    /// Exports hosts (passwords redacted by default).
473    Export {
474        /// Include secrets in the export.
475        #[arg(long)]
476        include_secrets: bool,
477        /// Output file (stdout if omitted). Written atomically with mode 0o600.
478        #[arg(long, short)]
479        output: Option<String>,
480        /// Agent-first JSON envelope (`event: vps-export`). Default body is **TOML**
481        /// even on non-TTY pipes (GAP-AUD-001/022). Redacted unless `--include-secrets`.
482        #[arg(long)]
483        json: bool,
484        /// Acknowledge writing plaintext secrets to stdout (pipe/non-TTY). Prefer `--output`.
485        #[arg(long)]
486        i_understand_secrets_on_stdout: bool,
487    },
488
489    /// Imports hosts from a TOML file or JSON `vps-export` envelope (EN + legacy PT keys).
490    Import {
491        /// Source file (TOML wire or JSON export envelope).
492        #[arg(long)]
493        file: PathBuf,
494        /// Allow hosts without full auth (redacted export / skeleton) — GAP-SSH-IMP-001.
495        #[arg(long)]
496        allow_incomplete: bool,
497    },
498}
499
500/// Actions of the `scp` subcommand (regular files only; no `-r` / no SFTP).
501#[derive(Debug, Subcommand)]
502pub enum ScpAction {
503    /// Uploads a local file to the remote host (regular files only).
504    Upload {
505        /// VPS name.
506        vps_name: String,
507        /// Local path.
508        local: PathBuf,
509        /// Remote path.
510        remote: PathBuf,
511        /// SSH password override.
512        #[arg(long, conflicts_with = "password_stdin")]
513        password: Option<String>,
514        /// Reads the SSH password from stdin.
515        #[arg(long)]
516        password_stdin: bool,
517        /// Private key path override.
518        #[arg(long)]
519        key: Option<String>,
520        /// Key passphrase.
521        #[arg(long, conflicts_with = "key_passphrase_stdin")]
522        key_passphrase: Option<String>,
523        /// Reads the key passphrase from stdin.
524        #[arg(long)]
525        key_passphrase_stdin: bool,
526        /// SSH timeout override in milliseconds (covers connect+transfer).
527        #[arg(long)]
528        timeout: Option<u64>,
529        /// Emits transfer JSON on stdout (GAP-SSH-IO-007).
530        #[arg(long)]
531        json: bool,
532    },
533
534    /// Downloads a remote file to the local host (regular files only).
535    Download {
536        /// VPS name.
537        vps_name: String,
538        /// Remote path.
539        remote: PathBuf,
540        /// Local path.
541        local: PathBuf,
542        /// SSH password override.
543        #[arg(long, conflicts_with = "password_stdin")]
544        password: Option<String>,
545        /// Reads the SSH password from stdin.
546        #[arg(long)]
547        password_stdin: bool,
548        /// Private key path override.
549        #[arg(long)]
550        key: Option<String>,
551        /// Key passphrase.
552        #[arg(long, conflicts_with = "key_passphrase_stdin")]
553        key_passphrase: Option<String>,
554        /// Reads the key passphrase from stdin.
555        #[arg(long)]
556        key_passphrase_stdin: bool,
557        /// SSH timeout override in milliseconds (covers connect+transfer).
558        #[arg(long)]
559        timeout: Option<u64>,
560        /// Emits transfer JSON on stdout (GAP-SSH-IO-007).
561        #[arg(long)]
562        json: bool,
563    },
564}
565
566/// Actions of the `secrets` subcommand (primary-key / AEAD).
567#[derive(Debug, Subcommand)]
568pub enum SecretsAction {
569    /// Shows encryption status (no sensitive material).
570    Status {
571        /// JSON output.
572        #[arg(long)]
573        json: bool,
574    },
575    /// Generates and stores the primary key (`secrets.key` or keyring). Never prints the key.
576    Init {
577        /// Store in the OS keyring instead of `secrets.key`.
578        #[arg(long)]
579        keyring: bool,
580        /// Overwrites an existing key.
581        #[arg(long)]
582        force: bool,
583        /// JSON success envelope (`event: secrets-init`).
584        #[arg(long)]
585        json: bool,
586    },
587    /// Rewrites `config.toml` re-encrypting secrets with the current key.
588    Reencrypt {
589        /// JSON success envelope (`event: secrets-reencrypt`).
590        #[arg(long)]
591        json: bool,
592    },
593}
594
595/// Parses CLI arguments.
596#[must_use]
597pub fn parse_args() -> CliArgs {
598    CliArgs::parse()
599}
600
601/// Initializes `tracing-subscriber`.
602///
603/// GAP-SSH-LOG-001 (0.3.9): default **error** (agent-first). `-v` → debug.
604/// `RUST_LOG` wins. Never defaults to INFO for JSON/non-TTY.
605pub 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        // quiet and human/agent default: error (no INFO prose on stderr).
614        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
626/// Writes shell completions to stdout.
627///
628/// GAP-SSH-CLI-003: broken pipe (EPIPE) does not panic — Unix pipe behavior.
629pub 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        // Other errors: best-effort on stderr without panic.
641        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    // Best-effort: inspect Debug of command for password-like flags present.
655    let s = format!("{:?}", args.command);
656    let sensitive = ["password:", "key_passphrase:", "sudo_password:", "su_password:"];
657    // clap Debug of Option::Some("…") — coarse but covers argv secrets.
658    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/// Resolves output format: explicit > `SSH_CLI_FORCE_TEXT` > JSON if non-TTY > Text.
667#[must_use]
668pub fn resolve_format(explicit: Option<OutputFormat>) -> OutputFormat {
669    if let Some(f) = explicit {
670        return f;
671    }
672    // Isolation for tests/scripts that force human prose in a pipe.
673    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
683/// Runs the requested subcommand.
684pub async fn dispatch(args: CliArgs) -> Result<()> {
685    let config_override = args.config_dir.clone();
686    // Aligns `secrets.key` with `--config-dir` / isolated tests.
687    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    // GAP-SSH-IO-003 / IO-004: centralized I/O policy.
695    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    // GAP-AUD-010: warn when secrets appear on argv (visible in `ps`).
701    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            // GAP-SSH-IO-007b: local --json or global --format json → JSON error envelope.
848            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            // GAP-SSH-IO-008: --json local or global format.
881            let json_efetivo = json || formato == OutputFormat::Json;
882            if json_efetivo {
883                crate::output::set_json_errors(true);
884            }
885            // GAP-SSH-CLI-005: auth parity with exec/scp (stdin + passphrase).
886            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            // GAP-SSH-CLI-006: auth parity with exec/scp (stdin + key + passphrase).
915            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}