Skip to main content

ssh_cli/
cli.rs

1//! Definição de argumentos CLI via `clap` derive e dispatcher.
2//!
3//! 1. CRUD de VPS — `vps add|list|remove|edit|show|path|doctor|export|import`
4//! 2. `connect` — grava arquivo irmão `active` (não campo TOML)
5//! 3. Execução one-shot — `exec|sudo-exec|su-exec|scp|tunnel|health-check`
6//! 4. `secrets` — master-key status/init/reencrypt (cifragem at-rest default)
7//! 5. Completions
8//!
9//! ZERO `.env` em runtime. ZERO telemetria. Ciclo one-shot: nascer → executar → morrer.
10
11use anyhow::Result;
12use clap::{Parser, Subcommand};
13use clap_complete::Shell;
14use std::path::PathBuf;
15
16/// Formato de saída suportado pela CLI.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
18pub enum FormatoSaida {
19    /// Texto legível por humanos (padrão).
20    #[default]
21    Text,
22    /// JSON estruturado.
23    Json,
24}
25
26/// Argumentos globais do ssh-cli.
27#[derive(Debug, Parser)]
28#[command(
29    name = "ssh-cli",
30    version = concat!(env!("CARGO_PKG_VERSION"), " (", env!("SSH_CLI_COMMIT_HASH"), ")"),
31    about = "CLI Rust one-shot multi-host XDG para LLMs operarem servidores via SSH.",
32    long_about = "ssh-cli: binário leve one-shot (nascer→executar→morrer). Multi-host em storage XDG sem .env. \
33Auth por senha ou chave. Sem telemetria."
34)]
35pub struct Argumentos {
36    /// Força o idioma da CLI (ex.: `pt-BR`, `en-US`).
37    #[arg(long, global = true, value_name = "LOCALE")]
38    pub lang: Option<String>,
39
40    /// Aumenta a verbosidade de logs em stderr.
41    #[arg(short, long, global = true)]
42    pub verbose: bool,
43
44    /// Suprime output não-JSON (modo silencioso).
45    #[arg(short, long, global = true)]
46    pub quiet: bool,
47
48    /// Override do diretório de configuração (útil para testes).
49    #[arg(long, global = true, value_name = "DIR")]
50    pub config_dir: Option<PathBuf>,
51
52    /// Desativa cores no output.
53    #[arg(long, global = true)]
54    pub no_color: bool,
55
56    /// Formato global de saída (text, json). Se omitido: JSON quando stdout não é TTY.
57    #[arg(long, global = true, value_enum)]
58    pub output_format: Option<FormatoSaida>,
59
60    /// Desabilita sudo-exec/su-exec nesta invocação (alias --disableSudo).
61    #[arg(long, global = true, alias = "disableSudo")]
62    pub disable_sudo: bool,
63
64    /// Substitui host key divergente no known_hosts TOFU.
65    #[arg(long, global = true)]
66    pub replace_host_key: bool,
67
68    /// Subcomando a executar.
69    #[command(subcommand)]
70    pub comando: Comando,
71}
72
73/// Subcomandos de primeiro nível.
74#[derive(Debug, Subcommand)]
75pub enum Comando {
76    /// Gerencia VPSs cadastradas.
77    Vps {
78        /// Ação específica do CRUD de VPS.
79        #[command(subcommand)]
80        acao: AcaoVps,
81    },
82
83    /// Define a VPS ativa (grava arquivo irmão `active` no diretório de config).
84    Connect {
85        /// Nome da VPS previamente adicionada via `vps add`.
86        nome: String,
87    },
88
89    /// Executa um comando na VPS via SSH (stdout/stderr capturados).
90    Exec {
91        /// Nome da VPS.
92        vps_nome: String,
93        /// Comando shell a executar.
94        comando: String,
95        /// Saída em JSON.
96        #[arg(long)]
97        json: bool,
98        /// Override de senha SSH.
99        #[arg(long, conflicts_with = "password_stdin")]
100        password: Option<String>,
101        /// Lê senha SSH de stdin.
102        #[arg(long)]
103        password_stdin: bool,
104        /// Override de caminho de chave privada.
105        #[arg(long)]
106        key: Option<String>,
107        /// Passphrase da chave (runtime).
108        #[arg(long, conflicts_with = "key_passphrase_stdin")]
109        key_passphrase: Option<String>,
110        /// Lê passphrase da chave de stdin.
111        #[arg(long)]
112        key_passphrase_stdin: bool,
113        /// Override de timeout em milissegundos.
114        #[arg(long)]
115        timeout: Option<u64>,
116        /// Comentário shell anexado (comentário shell para auditoria).
117        #[arg(long)]
118        description: Option<String>,
119    },
120
121    /// Executa um comando com `sudo` (packing `sh -c` seguro).
122    SudoExec {
123        /// Nome da VPS.
124        vps_nome: String,
125        /// Comando shell.
126        comando: String,
127        /// Saída em JSON.
128        #[arg(long)]
129        json: bool,
130        /// Override de senha SSH.
131        #[arg(long, conflicts_with = "password_stdin")]
132        password: Option<String>,
133        /// Lê senha SSH de stdin.
134        #[arg(long)]
135        password_stdin: bool,
136        /// Override de senha sudo.
137        #[arg(
138            long,
139            alias = "sudoPassword",
140            alias = "sudo_password",
141            conflicts_with = "sudo_password_stdin"
142        )]
143        sudo_password: Option<String>,
144        /// Lê senha sudo de stdin.
145        #[arg(long)]
146        sudo_password_stdin: bool,
147        /// Override de chave.
148        #[arg(long)]
149        key: Option<String>,
150        /// Passphrase da chave (runtime).
151        #[arg(long, conflicts_with = "key_passphrase_stdin")]
152        key_passphrase: Option<String>,
153        /// Lê passphrase da chave de stdin.
154        #[arg(long)]
155        key_passphrase_stdin: bool,
156        /// Override de timeout em milissegundos.
157        #[arg(long)]
158        timeout: Option<u64>,
159        /// Comentário shell anexado.
160        #[arg(long)]
161        description: Option<String>,
162    },
163
164    /// Executa um comando com elevação `su -` one-shot.
165    SuExec {
166        /// Nome da VPS.
167        vps_nome: String,
168        /// Comando shell.
169        comando: String,
170        /// Saída em JSON.
171        #[arg(long)]
172        json: bool,
173        /// Override de senha SSH.
174        #[arg(long, conflicts_with = "password_stdin")]
175        password: Option<String>,
176        /// Lê senha SSH de stdin (GAP-SSH-CLI-001).
177        #[arg(long)]
178        password_stdin: bool,
179        /// Override de senha su.
180        #[arg(
181            long,
182            alias = "suPassword",
183            alias = "su_password",
184            conflicts_with = "su_password_stdin"
185        )]
186        su_password: Option<String>,
187        /// Lê senha su de stdin.
188        #[arg(long)]
189        su_password_stdin: bool,
190        /// Override de chave.
191        #[arg(long)]
192        key: Option<String>,
193        /// Passphrase da chave (runtime).
194        #[arg(long, conflicts_with = "key_passphrase_stdin")]
195        key_passphrase: Option<String>,
196        /// Lê passphrase da chave de stdin.
197        #[arg(long)]
198        key_passphrase_stdin: bool,
199        /// Override de timeout.
200        #[arg(long)]
201        timeout: Option<u64>,
202        /// Comentário shell anexado.
203        #[arg(long)]
204        description: Option<String>,
205    },
206
207    /// Transferência de arquivos via SCP (upload/download).
208    Scp {
209        /// Ação específica do SCP.
210        #[command(subcommand)]
211        acao: AcaoScp,
212    },
213
214    /// Tunnel SSH com deadline obrigatório (one-shot limitado).
215    Tunnel {
216        /// Nome da VPS.
217        vps_nome: String,
218        /// Porta local.
219        porta_local: u16,
220        /// Host remoto.
221        host_remoto: String,
222        /// Porta remota.
223        porta_remota: u16,
224        /// Timeout obrigatório do tunnel em milissegundos.
225        #[arg(long)]
226        timeout_ms: u64,
227        /// Override de senha SSH.
228        #[arg(long, conflicts_with = "password_stdin")]
229        password: Option<String>,
230        /// Lê senha SSH de stdin (GAP-SSH-CLI-005).
231        #[arg(long)]
232        password_stdin: bool,
233        /// Override de caminho da chave privada.
234        #[arg(long)]
235        key: Option<String>,
236        /// Passphrase da chave.
237        #[arg(long, conflicts_with = "key_passphrase_stdin")]
238        key_passphrase: Option<String>,
239        /// Lê passphrase da chave de stdin (GAP-SSH-CLI-005).
240        #[arg(long)]
241        key_passphrase_stdin: bool,
242        /// Saída JSON agent-first quando o listener local sobe (GAP-SSH-IO-008).
243        #[arg(long)]
244        json: bool,
245    },
246
247    /// Verifica conectividade SSH com uma VPS.
248    HealthCheck {
249        /// Nome da VPS (usa ativa se omitido).
250        vps_nome: Option<String>,
251        /// Saída em JSON (GAP-SSH-IO-002).
252        #[arg(long)]
253        json: bool,
254        /// Override de senha SSH.
255        #[arg(long, conflicts_with = "password_stdin")]
256        password: Option<String>,
257        /// Lê senha SSH de stdin (GAP-SSH-CLI-006).
258        #[arg(long)]
259        password_stdin: bool,
260        /// Override de caminho da chave privada (GAP-SSH-CLI-006).
261        #[arg(long)]
262        key: Option<String>,
263        /// Passphrase da chave.
264        #[arg(long, conflicts_with = "key_passphrase_stdin")]
265        key_passphrase: Option<String>,
266        /// Lê passphrase da chave de stdin (GAP-SSH-CLI-006).
267        #[arg(long)]
268        key_passphrase_stdin: bool,
269        /// Override de timeout SSH em milissegundos (GAP-SSH-CLI-004).
270        #[arg(long)]
271        timeout: Option<u64>,
272    },
273
274    /// Gerencia master-key e cifragem at-rest de secrets (one-shot).
275    Secrets {
276        /// Ação de secrets.
277        #[command(subcommand)]
278        acao: AcaoSecrets,
279    },
280
281    /// Gera completions de shell.
282    Completions {
283        /// Shell alvo.
284        #[arg(value_enum)]
285        shell: Shell,
286    },
287}
288
289/// Ações do subcomando `vps`.
290#[derive(Debug, Subcommand)]
291pub enum AcaoVps {
292    /// Adiciona uma nova VPS ao registro.
293    Add {
294        /// Nome único da VPS.
295        #[arg(long)]
296        name: String,
297        /// Hostname ou IP.
298        #[arg(long)]
299        host: String,
300        /// Porta SSH.
301        #[arg(long, default_value_t = 22)]
302        port: u16,
303        /// Usuário SSH.
304        #[arg(long)]
305        user: String,
306        /// Senha SSH.
307        #[arg(long, conflicts_with = "password_stdin")]
308        password: Option<String>,
309        /// Lê senha de stdin.
310        #[arg(long)]
311        password_stdin: bool,
312        /// Caminho da chave privada OpenSSH.
313        #[arg(long)]
314        key: Option<String>,
315        /// Passphrase da chave.
316        #[arg(long)]
317        key_passphrase: Option<String>,
318        /// Timeout em milissegundos (default 60000).
319        #[arg(long, default_value_t = 60_000)]
320        timeout: u64,
321        /// Limite de caracteres do comando (entrada). Alias legado: maxChars.
322        #[arg(long)]
323        max_command_chars: Option<String>,
324        /// Limite de caracteres de saída.
325        #[arg(long)]
326        max_output_chars: Option<String>,
327        /// Alias legado: mapeia para max_command_chars .
328        #[arg(long, alias = "maxChars")]
329        max_chars: Option<String>,
330        /// Senha para `sudo`.
331        #[arg(
332            long,
333            alias = "sudoPassword",
334            alias = "sudo_password",
335            conflicts_with = "sudo_password_stdin"
336        )]
337        sudo_password: Option<String>,
338        /// Lê senha sudo de stdin.
339        #[arg(long)]
340        sudo_password_stdin: bool,
341        /// Senha para `su -`.
342        #[arg(
343            long,
344            alias = "suPassword",
345            alias = "su_password",
346            conflicts_with = "su_password_stdin"
347        )]
348        su_password: Option<String>,
349        /// Lê senha su de stdin.
350        #[arg(long)]
351        su_password_stdin: bool,
352        /// Desabilita sudo/su neste host.
353        #[arg(long, default_value_t = false)]
354        disable_sudo: bool,
355        /// Roda health-check após add.
356        #[arg(long)]
357        check: bool,
358    },
359
360    /// Lista todas as VPSs (senhas mascaradas).
361    List {
362        /// Saída em JSON.
363        #[arg(long)]
364        json: bool,
365    },
366
367    /// Remove uma VPS do registro.
368    Remove {
369        /// Nome da VPS a remover.
370        nome: String,
371    },
372
373    /// Edita campos de uma VPS existente.
374    Edit {
375        /// Nome da VPS a editar.
376        nome: String,
377        /// Novo hostname/IP.
378        #[arg(long)]
379        host: Option<String>,
380        /// Nova porta SSH.
381        #[arg(long)]
382        port: Option<u16>,
383        /// Novo usuário.
384        #[arg(long)]
385        user: Option<String>,
386        /// Nova senha.
387        #[arg(long, conflicts_with = "password_stdin")]
388        password: Option<String>,
389        /// Lê senha de stdin.
390        #[arg(long)]
391        password_stdin: bool,
392        /// Nova chave.
393        #[arg(long)]
394        key: Option<String>,
395        /// Nova passphrase.
396        #[arg(long)]
397        key_passphrase: Option<String>,
398        /// Novo timeout.
399        #[arg(long)]
400        timeout: Option<u64>,
401        /// Novo max command chars.
402        #[arg(long)]
403        max_command_chars: Option<String>,
404        /// Novo max output chars.
405        #[arg(long)]
406        max_output_chars: Option<String>,
407        /// Alias legado maxChars → command.
408        #[arg(long, alias = "maxChars")]
409        max_chars: Option<String>,
410        /// Nova senha sudo.
411        #[arg(
412            long,
413            alias = "sudoPassword",
414            alias = "sudo_password",
415            conflicts_with = "sudo_password_stdin"
416        )]
417        sudo_password: Option<String>,
418        /// Lê senha sudo de stdin.
419        #[arg(long)]
420        sudo_password_stdin: bool,
421        /// Nova senha su.
422        #[arg(
423            long,
424            alias = "suPassword",
425            alias = "su_password",
426            conflicts_with = "su_password_stdin"
427        )]
428        su_password: Option<String>,
429        /// Lê senha su de stdin.
430        #[arg(long)]
431        su_password_stdin: bool,
432        /// Define disable_sudo.
433        #[arg(long)]
434        disable_sudo: Option<bool>,
435    },
436
437    /// Exibe detalhes de uma VPS (senhas mascaradas).
438    Show {
439        /// Nome da VPS.
440        nome: String,
441        /// Saída em JSON.
442        #[arg(long)]
443        json: bool,
444    },
445
446    /// Exibe o caminho do arquivo de configuração.
447    Path,
448
449    /// Diagnóstico de camadas XDG / path / schema.
450    Doctor {
451        /// Saída em JSON.
452        #[arg(long)]
453        json: bool,
454    },
455
456    /// Exporta hosts (senhas redacted por default).
457    Export {
458        /// Inclui segredos no export.
459        #[arg(long)]
460        include_secrets: bool,
461        /// Arquivo de saída (stdout se omitido).
462        #[arg(long, short)]
463        output: Option<String>,
464    },
465
466    /// Importa hosts de um TOML.
467    Import {
468        /// Arquivo de origem.
469        #[arg(long)]
470        file: PathBuf,
471        /// Permite hosts sem auth completa (export redacted / esqueleto) — GAP-SSH-IMP-001.
472        #[arg(long)]
473        allow_incomplete: bool,
474    },
475}
476
477/// Ações do subcomando `scp` (arquivo regular; sem `-r` / sem SFTP).
478#[derive(Debug, Subcommand)]
479pub enum AcaoScp {
480    /// Upload de arquivo local para remote (regular files only).
481    Upload {
482        /// Nome da VPS.
483        vps_nome: String,
484        /// Caminho local.
485        local: PathBuf,
486        /// Caminho remote.
487        remote: PathBuf,
488        /// Override de senha SSH.
489        #[arg(long, conflicts_with = "password_stdin")]
490        password: Option<String>,
491        /// Lê senha SSH de stdin.
492        #[arg(long)]
493        password_stdin: bool,
494        /// Override de caminho da chave privada.
495        #[arg(long)]
496        key: Option<String>,
497        /// Passphrase da chave.
498        #[arg(long, conflicts_with = "key_passphrase_stdin")]
499        key_passphrase: Option<String>,
500        /// Lê passphrase da chave de stdin.
501        #[arg(long)]
502        key_passphrase_stdin: bool,
503        /// Override de timeout SSH em milissegundos (cobre connect+transfer).
504        #[arg(long)]
505        timeout: Option<u64>,
506        /// Emite JSON de transferência em stdout (GAP-SSH-IO-007).
507        #[arg(long)]
508        json: bool,
509    },
510
511    /// Download de arquivo remote para local (regular files only).
512    Download {
513        /// Nome da VPS.
514        vps_nome: String,
515        /// Caminho remote.
516        remote: PathBuf,
517        /// Caminho local.
518        local: PathBuf,
519        /// Override de senha SSH.
520        #[arg(long, conflicts_with = "password_stdin")]
521        password: Option<String>,
522        /// Lê senha SSH de stdin.
523        #[arg(long)]
524        password_stdin: bool,
525        /// Override de caminho da chave privada.
526        #[arg(long)]
527        key: Option<String>,
528        /// Passphrase da chave.
529        #[arg(long, conflicts_with = "key_passphrase_stdin")]
530        key_passphrase: Option<String>,
531        /// Lê passphrase da chave de stdin.
532        #[arg(long)]
533        key_passphrase_stdin: bool,
534        /// Override de timeout SSH em milissegundos (cobre connect+transfer).
535        #[arg(long)]
536        timeout: Option<u64>,
537        /// Emite JSON de transferência em stdout (GAP-SSH-IO-007).
538        #[arg(long)]
539        json: bool,
540    },
541}
542
543/// Ações do subcomando `secrets` (master-key / AEAD).
544#[derive(Debug, Subcommand)]
545pub enum AcaoSecrets {
546    /// Mostra status da cifragem (sem material sensível).
547    Status {
548        /// Saída em JSON.
549        #[arg(long)]
550        json: bool,
551    },
552    /// Gera e grava master-key (`secrets.key` ou keyring). Nunca imprime a chave.
553    Init {
554        /// Grava no OS keyring em vez de `secrets.key`.
555        #[arg(long)]
556        keyring: bool,
557        /// Sobrescreve chave existente.
558        #[arg(long)]
559        force: bool,
560    },
561    /// Regrava `config.toml` re-cifando secrets com a chave atual.
562    Reencrypt,
563}
564
565/// Faz parsing dos argumentos da CLI.
566#[must_use]
567pub fn parse_args() -> Argumentos {
568    Argumentos::parse()
569}
570
571/// Inicializa `tracing-subscriber`.
572///
573/// GAP-SSH-LOG-001 (0.3.9): default **error** (agent-first). `-v` → debug.
574/// `RUST_LOG` vence tudo. Nunca INFO por omissão em JSON/non-TTY.
575pub fn inicializar_logs(args: &Argumentos) {
576    use tracing_subscriber::{fmt, EnvFilter};
577
578    let filter = if std::env::var("RUST_LOG").is_ok() {
579        EnvFilter::from_default_env()
580    } else if args.verbose {
581        EnvFilter::new("debug")
582    } else {
583        // quiet e default humano/agente: error (sem prosa INFO em stderr).
584        let _ = args.quiet;
585        EnvFilter::new("error")
586    };
587
588    let _ = fmt()
589        .with_env_filter(filter)
590        .with_writer(std::io::stderr)
591        .with_target(false)
592        .with_ansi(false)
593        .try_init();
594}
595
596/// Gera completions de shell para stdout.
597///
598/// GAP-SSH-CLI-003: broken pipe (EPIPE) não panic — comportamento Unix de pipe.
599pub fn gerar_completions(shell: Shell) {
600    use clap::CommandFactory;
601    use std::io::Write;
602    let mut cmd = Argumentos::command();
603    let mut buf: Vec<u8> = Vec::new();
604    clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
605    let mut out = std::io::stdout().lock();
606    if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
607        if e.kind() == std::io::ErrorKind::BrokenPipe {
608            return;
609        }
610        // Outros erros: best-effort em stderr sem panic.
611        let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
612    }
613}
614
615fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
616    if flag {
617        Ok(Some(crate::vps::ler_segredo_stdin()?))
618    } else {
619        Ok(valor)
620    }
621}
622
623/// Resolve formato de saída: explícito > `SSH_CLI_FORCE_TEXT` > JSON se não-TTY > Text.
624#[must_use]
625pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
626    if let Some(f) = explicit {
627        return f;
628    }
629    // Isolamento de testes / scripts que forçam prosa humana em pipe.
630    if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
631        return FormatoSaida::Text;
632    }
633    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
634        FormatoSaida::Json
635    } else {
636        FormatoSaida::Text
637    }
638}
639
640/// Executa o subcomando solicitado.
641pub async fn executar(args: Argumentos) -> Result<()> {
642    let config_override = args.config_dir.clone();
643    // Alinha `secrets.key` com `--config-dir` / testes isolados.
644    crate::secrets::definir_diretorio_config(config_override.clone());
645    let formato = resolver_formato(args.output_format);
646    // GAP-SSH-IO-003 / IO-004: política de I/O centralizada.
647    crate::output::definir_quiet(args.quiet);
648    crate::output::definir_json_erros(formato == FormatoSaida::Json);
649    let disable_sudo = args.disable_sudo;
650    let replace_host_key = args.replace_host_key;
651
652    match args.comando {
653        Comando::Vps { acao } => {
654            crate::vps::executar_comando_vps(acao, config_override, formato).await
655        }
656        Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
657        Comando::Exec {
658            vps_nome,
659            comando,
660            json,
661            password,
662            password_stdin,
663            key,
664            key_passphrase,
665            key_passphrase_stdin,
666            timeout,
667            description,
668        } => {
669            let password = ler_stdin_se(password_stdin, password)?;
670            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
671            let opts = crate::vps::OpcoesExec {
672                password,
673                key,
674                key_passphrase,
675                timeout,
676                description,
677                replace_host_key,
678                disable_sudo,
679                ..Default::default()
680            };
681            crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
682                .await
683        }
684        Comando::SudoExec {
685            vps_nome,
686            comando,
687            json,
688            password,
689            password_stdin,
690            sudo_password,
691            sudo_password_stdin,
692            key,
693            key_passphrase,
694            key_passphrase_stdin,
695            timeout,
696            description,
697        } => {
698            let password = ler_stdin_se(password_stdin, password)?;
699            let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
700            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
701            let opts = crate::vps::OpcoesExec {
702                password,
703                sudo_password,
704                key,
705                key_passphrase,
706                timeout,
707                description,
708                replace_host_key,
709                disable_sudo,
710                ..Default::default()
711            };
712            crate::vps::executar_sudo_exec(
713                &vps_nome,
714                &comando,
715                config_override,
716                formato,
717                json,
718                opts,
719            )
720            .await
721        }
722        Comando::SuExec {
723            vps_nome,
724            comando,
725            json,
726            password,
727            password_stdin,
728            su_password,
729            su_password_stdin,
730            key,
731            key_passphrase,
732            key_passphrase_stdin,
733            timeout,
734            description,
735        } => {
736            let password = ler_stdin_se(password_stdin, password)?;
737            let su_password = ler_stdin_se(su_password_stdin, su_password)?;
738            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
739            let opts = crate::vps::OpcoesExec {
740                password,
741                su_password,
742                key,
743                key_passphrase,
744                timeout,
745                description,
746                replace_host_key,
747                disable_sudo,
748                ..Default::default()
749            };
750            crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
751                .await
752        }
753        Comando::Scp { acao } => {
754            let (
755                password,
756                password_stdin,
757                key,
758                key_passphrase,
759                key_passphrase_stdin,
760                timeout,
761                json_local,
762            ) = match &acao {
763                AcaoScp::Upload {
764                    password,
765                    password_stdin,
766                    key,
767                    key_passphrase,
768                    key_passphrase_stdin,
769                    timeout,
770                    json,
771                    ..
772                }
773                | AcaoScp::Download {
774                    password,
775                    password_stdin,
776                    key,
777                    key_passphrase,
778                    key_passphrase_stdin,
779                    timeout,
780                    json,
781                    ..
782                } => (
783                    password.clone(),
784                    *password_stdin,
785                    key.clone(),
786                    key_passphrase.clone(),
787                    *key_passphrase_stdin,
788                    *timeout,
789                    *json,
790                ),
791            };
792            let password = ler_stdin_se(password_stdin, password)?;
793            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
794            // GAP-SSH-IO-007b: --json local ou --format json global → envelope de erro JSON.
795            let json_efetivo = json_local || formato == FormatoSaida::Json;
796            if json_efetivo {
797                crate::output::definir_json_erros(true);
798            }
799            crate::scp::executar_scp(
800                acao,
801                config_override,
802                crate::scp::OpcoesScp {
803                    password,
804                    key,
805                    key_passphrase,
806                    timeout,
807                    replace_host_key,
808                    json: json_efetivo,
809                },
810            )
811            .await
812        }
813        Comando::Tunnel {
814            vps_nome,
815            porta_local,
816            host_remoto,
817            porta_remota,
818            timeout_ms,
819            password,
820            password_stdin,
821            key,
822            key_passphrase,
823            key_passphrase_stdin,
824            json,
825        } => {
826            // GAP-SSH-IO-008: --json local ou --format json global.
827            let json_efetivo = json || formato == FormatoSaida::Json;
828            if json_efetivo {
829                crate::output::definir_json_erros(true);
830            }
831            // GAP-SSH-CLI-005: paridade auth com exec/scp (stdin + passphrase).
832            let password = ler_stdin_se(password_stdin, password)?;
833            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
834            crate::tunnel::executar_tunnel(
835                &vps_nome,
836                porta_local,
837                &host_remoto,
838                porta_remota,
839                config_override,
840                password,
841                key,
842                key_passphrase,
843                timeout_ms,
844                replace_host_key,
845                json_efetivo,
846            )
847            .await
848        }
849        Comando::HealthCheck {
850            vps_nome,
851            json,
852            password,
853            password_stdin,
854            key,
855            key_passphrase,
856            key_passphrase_stdin,
857            timeout,
858        } => {
859            // GAP-SSH-CLI-006: paridade auth com exec/scp (stdin + key + passphrase).
860            let password = ler_stdin_se(password_stdin, password)?;
861            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
862            crate::vps::executar_health_check(
863                vps_nome.as_deref(),
864                config_override,
865                formato,
866                json,
867                password,
868                timeout,
869                key,
870                key_passphrase,
871                replace_host_key,
872            )
873            .await
874        }
875        Comando::Secrets { acao } => {
876            crate::vps::executar_comando_secrets(acao, config_override, formato).await
877        }
878        Comando::Completions { shell } => {
879            gerar_completions(shell);
880            Ok(())
881        }
882    }
883}
884
885#[cfg(test)]
886mod testes {
887    use super::*;
888    use clap::Parser;
889
890    #[test]
891    fn parser_entende_tunnel_com_timeout() {
892        let args = Argumentos::try_parse_from([
893            "ssh-cli",
894            "tunnel",
895            "vps-a",
896            "8080",
897            "127.0.0.1",
898            "5432",
899            "--timeout-ms",
900            "5000",
901            "--json",
902        ])
903        .expect("tunnel");
904        match args.comando {
905            Comando::Tunnel {
906                timeout_ms,
907                porta_local,
908                json,
909                ..
910            } => {
911                assert_eq!(timeout_ms, 5000);
912                assert_eq!(porta_local, 8080);
913                assert!(json);
914            }
915            _ => panic!("esperado tunnel"),
916        }
917    }
918
919    #[test]
920    fn parser_vps_add_key() {
921        let args = Argumentos::try_parse_from([
922            "ssh-cli",
923            "vps",
924            "add",
925            "--name",
926            "x",
927            "--host",
928            "h",
929            "--user",
930            "u",
931            "--key",
932            "/tmp/id_ed25519",
933        ])
934        .expect("add key");
935        match args.comando {
936            Comando::Vps {
937                acao: AcaoVps::Add { key, password, .. },
938            } => {
939                assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
940                assert!(password.is_none());
941            }
942            _ => panic!("esperado add"),
943        }
944    }
945
946    #[test]
947    fn parser_sudo_exec_description() {
948        let args = Argumentos::try_parse_from([
949            "ssh-cli",
950            "sudo-exec",
951            "v",
952            "id",
953            "--description",
954            "who am i",
955        ])
956        .unwrap();
957        match args.comando {
958            Comando::SudoExec { description, .. } => {
959                assert_eq!(description.as_deref(), Some("who am i"));
960            }
961            _ => panic!("sudo-exec"),
962        }
963    }
964
965    #[test]
966    fn parser_su_exec() {
967        let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
968        assert!(matches!(args.comando, Comando::SuExec { .. }));
969    }
970
971    #[test]
972    fn parser_disable_sudo_global() {
973        let args =
974            Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
975        assert!(args.disable_sudo);
976    }
977
978    #[test]
979    fn parser_doctor() {
980        let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
981        match args.comando {
982            Comando::Vps {
983                acao: AcaoVps::Doctor { json },
984            } => assert!(json),
985            _ => panic!("doctor"),
986        }
987    }
988}