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)]
229        password: Option<String>,
230        /// Override de chave.
231        #[arg(long)]
232        key: Option<String>,
233        /// Saída JSON agent-first quando o listener local sobe (GAP-SSH-IO-008).
234        #[arg(long)]
235        json: bool,
236    },
237
238    /// Verifica conectividade SSH com uma VPS.
239    HealthCheck {
240        /// Nome da VPS (usa ativa se omitido).
241        vps_nome: Option<String>,
242        /// Saída em JSON (GAP-SSH-IO-002).
243        #[arg(long)]
244        json: bool,
245        /// Override de senha SSH.
246        #[arg(long)]
247        password: Option<String>,
248        /// Override de timeout SSH em milissegundos (GAP-SSH-CLI-004).
249        #[arg(long)]
250        timeout: Option<u64>,
251    },
252
253    /// Gerencia master-key e cifragem at-rest de secrets (one-shot).
254    Secrets {
255        /// Ação de secrets.
256        #[command(subcommand)]
257        acao: AcaoSecrets,
258    },
259
260    /// Gera completions de shell.
261    Completions {
262        /// Shell alvo.
263        #[arg(value_enum)]
264        shell: Shell,
265    },
266}
267
268/// Ações do subcomando `vps`.
269#[derive(Debug, Subcommand)]
270pub enum AcaoVps {
271    /// Adiciona uma nova VPS ao registro.
272    Add {
273        /// Nome único da VPS.
274        #[arg(long)]
275        name: String,
276        /// Hostname ou IP.
277        #[arg(long)]
278        host: String,
279        /// Porta SSH.
280        #[arg(long, default_value_t = 22)]
281        port: u16,
282        /// Usuário SSH.
283        #[arg(long)]
284        user: String,
285        /// Senha SSH.
286        #[arg(long, conflicts_with = "password_stdin")]
287        password: Option<String>,
288        /// Lê senha de stdin.
289        #[arg(long)]
290        password_stdin: bool,
291        /// Caminho da chave privada OpenSSH.
292        #[arg(long)]
293        key: Option<String>,
294        /// Passphrase da chave.
295        #[arg(long)]
296        key_passphrase: Option<String>,
297        /// Timeout em milissegundos (default 60000).
298        #[arg(long, default_value_t = 60_000)]
299        timeout: u64,
300        /// Limite de caracteres do comando (entrada). Alias legado: maxChars.
301        #[arg(long)]
302        max_command_chars: Option<String>,
303        /// Limite de caracteres de saída.
304        #[arg(long)]
305        max_output_chars: Option<String>,
306        /// Alias legado: mapeia para max_command_chars .
307        #[arg(long, alias = "maxChars")]
308        max_chars: Option<String>,
309        /// Senha para `sudo`.
310        #[arg(
311            long,
312            alias = "sudoPassword",
313            alias = "sudo_password",
314            conflicts_with = "sudo_password_stdin"
315        )]
316        sudo_password: Option<String>,
317        /// Lê senha sudo de stdin.
318        #[arg(long)]
319        sudo_password_stdin: bool,
320        /// Senha para `su -`.
321        #[arg(
322            long,
323            alias = "suPassword",
324            alias = "su_password",
325            conflicts_with = "su_password_stdin"
326        )]
327        su_password: Option<String>,
328        /// Lê senha su de stdin.
329        #[arg(long)]
330        su_password_stdin: bool,
331        /// Desabilita sudo/su neste host.
332        #[arg(long, default_value_t = false)]
333        disable_sudo: bool,
334        /// Roda health-check após add.
335        #[arg(long)]
336        check: bool,
337    },
338
339    /// Lista todas as VPSs (senhas mascaradas).
340    List {
341        /// Saída em JSON.
342        #[arg(long)]
343        json: bool,
344    },
345
346    /// Remove uma VPS do registro.
347    Remove {
348        /// Nome da VPS a remover.
349        nome: String,
350    },
351
352    /// Edita campos de uma VPS existente.
353    Edit {
354        /// Nome da VPS a editar.
355        nome: String,
356        /// Novo hostname/IP.
357        #[arg(long)]
358        host: Option<String>,
359        /// Nova porta SSH.
360        #[arg(long)]
361        port: Option<u16>,
362        /// Novo usuário.
363        #[arg(long)]
364        user: Option<String>,
365        /// Nova senha.
366        #[arg(long, conflicts_with = "password_stdin")]
367        password: Option<String>,
368        /// Lê senha de stdin.
369        #[arg(long)]
370        password_stdin: bool,
371        /// Nova chave.
372        #[arg(long)]
373        key: Option<String>,
374        /// Nova passphrase.
375        #[arg(long)]
376        key_passphrase: Option<String>,
377        /// Novo timeout.
378        #[arg(long)]
379        timeout: Option<u64>,
380        /// Novo max command chars.
381        #[arg(long)]
382        max_command_chars: Option<String>,
383        /// Novo max output chars.
384        #[arg(long)]
385        max_output_chars: Option<String>,
386        /// Alias legado maxChars → command.
387        #[arg(long, alias = "maxChars")]
388        max_chars: Option<String>,
389        /// Nova senha sudo.
390        #[arg(
391            long,
392            alias = "sudoPassword",
393            alias = "sudo_password",
394            conflicts_with = "sudo_password_stdin"
395        )]
396        sudo_password: Option<String>,
397        /// Lê senha sudo de stdin.
398        #[arg(long)]
399        sudo_password_stdin: bool,
400        /// Nova senha su.
401        #[arg(
402            long,
403            alias = "suPassword",
404            alias = "su_password",
405            conflicts_with = "su_password_stdin"
406        )]
407        su_password: Option<String>,
408        /// Lê senha su de stdin.
409        #[arg(long)]
410        su_password_stdin: bool,
411        /// Define disable_sudo.
412        #[arg(long)]
413        disable_sudo: Option<bool>,
414    },
415
416    /// Exibe detalhes de uma VPS (senhas mascaradas).
417    Show {
418        /// Nome da VPS.
419        nome: String,
420        /// Saída em JSON.
421        #[arg(long)]
422        json: bool,
423    },
424
425    /// Exibe o caminho do arquivo de configuração.
426    Path,
427
428    /// Diagnóstico de camadas XDG / path / schema.
429    Doctor {
430        /// Saída em JSON.
431        #[arg(long)]
432        json: bool,
433    },
434
435    /// Exporta hosts (senhas redacted por default).
436    Export {
437        /// Inclui segredos no export.
438        #[arg(long)]
439        include_secrets: bool,
440        /// Arquivo de saída (stdout se omitido).
441        #[arg(long, short)]
442        output: Option<String>,
443    },
444
445    /// Importa hosts de um TOML.
446    Import {
447        /// Arquivo de origem.
448        #[arg(long)]
449        file: PathBuf,
450        /// Permite hosts sem auth completa (export redacted / esqueleto) — GAP-SSH-IMP-001.
451        #[arg(long)]
452        allow_incomplete: bool,
453    },
454}
455
456/// Ações do subcomando `scp` (arquivo regular; sem `-r` / sem SFTP).
457#[derive(Debug, Subcommand)]
458pub enum AcaoScp {
459    /// Upload de arquivo local para remote (regular files only).
460    Upload {
461        /// Nome da VPS.
462        vps_nome: String,
463        /// Caminho local.
464        local: PathBuf,
465        /// Caminho remote.
466        remote: PathBuf,
467        /// Override de senha SSH.
468        #[arg(long, conflicts_with = "password_stdin")]
469        password: Option<String>,
470        /// Lê senha SSH de stdin.
471        #[arg(long)]
472        password_stdin: bool,
473        /// Override de caminho da chave privada.
474        #[arg(long)]
475        key: Option<String>,
476        /// Passphrase da chave.
477        #[arg(long, conflicts_with = "key_passphrase_stdin")]
478        key_passphrase: Option<String>,
479        /// Lê passphrase da chave de stdin.
480        #[arg(long)]
481        key_passphrase_stdin: bool,
482        /// Override de timeout SSH em milissegundos (cobre connect+transfer).
483        #[arg(long)]
484        timeout: Option<u64>,
485        /// Emite JSON de transferência em stdout (GAP-SSH-IO-007).
486        #[arg(long)]
487        json: bool,
488    },
489
490    /// Download de arquivo remote para local (regular files only).
491    Download {
492        /// Nome da VPS.
493        vps_nome: String,
494        /// Caminho remote.
495        remote: PathBuf,
496        /// Caminho local.
497        local: PathBuf,
498        /// Override de senha SSH.
499        #[arg(long, conflicts_with = "password_stdin")]
500        password: Option<String>,
501        /// Lê senha SSH de stdin.
502        #[arg(long)]
503        password_stdin: bool,
504        /// Override de caminho da chave privada.
505        #[arg(long)]
506        key: Option<String>,
507        /// Passphrase da chave.
508        #[arg(long, conflicts_with = "key_passphrase_stdin")]
509        key_passphrase: Option<String>,
510        /// Lê passphrase da chave de stdin.
511        #[arg(long)]
512        key_passphrase_stdin: bool,
513        /// Override de timeout SSH em milissegundos (cobre connect+transfer).
514        #[arg(long)]
515        timeout: Option<u64>,
516        /// Emite JSON de transferência em stdout (GAP-SSH-IO-007).
517        #[arg(long)]
518        json: bool,
519    },
520}
521
522/// Ações do subcomando `secrets` (master-key / AEAD).
523#[derive(Debug, Subcommand)]
524pub enum AcaoSecrets {
525    /// Mostra status da cifragem (sem material sensível).
526    Status {
527        /// Saída em JSON.
528        #[arg(long)]
529        json: bool,
530    },
531    /// Gera e grava master-key (`secrets.key` ou keyring). Nunca imprime a chave.
532    Init {
533        /// Grava no OS keyring em vez de `secrets.key`.
534        #[arg(long)]
535        keyring: bool,
536        /// Sobrescreve chave existente.
537        #[arg(long)]
538        force: bool,
539    },
540    /// Regrava `config.toml` re-cifando secrets com a chave atual.
541    Reencrypt,
542}
543
544/// Faz parsing dos argumentos da CLI.
545#[must_use]
546pub fn parse_args() -> Argumentos {
547    Argumentos::parse()
548}
549
550/// Inicializa `tracing-subscriber`.
551///
552/// GAP-SSH-LOG-001 (0.3.9): default **error** (agent-first). `-v` → debug.
553/// `RUST_LOG` vence tudo. Nunca INFO por omissão em JSON/non-TTY.
554pub fn inicializar_logs(args: &Argumentos) {
555    use tracing_subscriber::{fmt, EnvFilter};
556
557    let filter = if std::env::var("RUST_LOG").is_ok() {
558        EnvFilter::from_default_env()
559    } else if args.verbose {
560        EnvFilter::new("debug")
561    } else {
562        // quiet e default humano/agente: error (sem prosa INFO em stderr).
563        let _ = args.quiet;
564        EnvFilter::new("error")
565    };
566
567    let _ = fmt()
568        .with_env_filter(filter)
569        .with_writer(std::io::stderr)
570        .with_target(false)
571        .with_ansi(false)
572        .try_init();
573}
574
575/// Gera completions de shell para stdout.
576///
577/// GAP-SSH-CLI-003: broken pipe (EPIPE) não panic — comportamento Unix de pipe.
578pub fn gerar_completions(shell: Shell) {
579    use clap::CommandFactory;
580    use std::io::Write;
581    let mut cmd = Argumentos::command();
582    let mut buf: Vec<u8> = Vec::new();
583    clap_complete::generate(shell, &mut cmd, "ssh-cli", &mut buf);
584    let mut out = std::io::stdout().lock();
585    if let Err(e) = out.write_all(&buf).and_then(|_| out.flush()) {
586        if e.kind() == std::io::ErrorKind::BrokenPipe {
587            return;
588        }
589        // Outros erros: best-effort em stderr sem panic.
590        let _ = writeln!(std::io::stderr(), "erro ao escrever completions: {e}");
591    }
592}
593
594fn ler_stdin_se(flag: bool, valor: Option<String>) -> Result<Option<String>> {
595    if flag {
596        Ok(Some(crate::vps::ler_segredo_stdin()?))
597    } else {
598        Ok(valor)
599    }
600}
601
602/// Resolve formato de saída: explícito > `SSH_CLI_FORCE_TEXT` > JSON se não-TTY > Text.
603#[must_use]
604pub fn resolver_formato(explicit: Option<FormatoSaida>) -> FormatoSaida {
605    if let Some(f) = explicit {
606        return f;
607    }
608    // Isolamento de testes / scripts que forçam prosa humana em pipe.
609    if std::env::var_os("SSH_CLI_FORCE_TEXT").is_some() {
610        return FormatoSaida::Text;
611    }
612    if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
613        FormatoSaida::Json
614    } else {
615        FormatoSaida::Text
616    }
617}
618
619/// Executa o subcomando solicitado.
620pub async fn executar(args: Argumentos) -> Result<()> {
621    let config_override = args.config_dir.clone();
622    // Alinha `secrets.key` com `--config-dir` / testes isolados.
623    crate::secrets::definir_diretorio_config(config_override.clone());
624    let formato = resolver_formato(args.output_format);
625    // GAP-SSH-IO-003 / IO-004: política de I/O centralizada.
626    crate::output::definir_quiet(args.quiet);
627    crate::output::definir_json_erros(formato == FormatoSaida::Json);
628    let disable_sudo = args.disable_sudo;
629    let replace_host_key = args.replace_host_key;
630
631    match args.comando {
632        Comando::Vps { acao } => {
633            crate::vps::executar_comando_vps(acao, config_override, formato).await
634        }
635        Comando::Connect { nome } => crate::vps::executar_connect(&nome, config_override).await,
636        Comando::Exec {
637            vps_nome,
638            comando,
639            json,
640            password,
641            password_stdin,
642            key,
643            key_passphrase,
644            key_passphrase_stdin,
645            timeout,
646            description,
647        } => {
648            let password = ler_stdin_se(password_stdin, password)?;
649            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
650            let opts = crate::vps::OpcoesExec {
651                password,
652                key,
653                key_passphrase,
654                timeout,
655                description,
656                replace_host_key,
657                disable_sudo,
658                ..Default::default()
659            };
660            crate::vps::executar_exec(&vps_nome, &comando, config_override, formato, json, opts)
661                .await
662        }
663        Comando::SudoExec {
664            vps_nome,
665            comando,
666            json,
667            password,
668            password_stdin,
669            sudo_password,
670            sudo_password_stdin,
671            key,
672            key_passphrase,
673            key_passphrase_stdin,
674            timeout,
675            description,
676        } => {
677            let password = ler_stdin_se(password_stdin, password)?;
678            let sudo_password = ler_stdin_se(sudo_password_stdin, sudo_password)?;
679            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
680            let opts = crate::vps::OpcoesExec {
681                password,
682                sudo_password,
683                key,
684                key_passphrase,
685                timeout,
686                description,
687                replace_host_key,
688                disable_sudo,
689                ..Default::default()
690            };
691            crate::vps::executar_sudo_exec(
692                &vps_nome,
693                &comando,
694                config_override,
695                formato,
696                json,
697                opts,
698            )
699            .await
700        }
701        Comando::SuExec {
702            vps_nome,
703            comando,
704            json,
705            password,
706            password_stdin,
707            su_password,
708            su_password_stdin,
709            key,
710            key_passphrase,
711            key_passphrase_stdin,
712            timeout,
713            description,
714        } => {
715            let password = ler_stdin_se(password_stdin, password)?;
716            let su_password = ler_stdin_se(su_password_stdin, su_password)?;
717            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
718            let opts = crate::vps::OpcoesExec {
719                password,
720                su_password,
721                key,
722                key_passphrase,
723                timeout,
724                description,
725                replace_host_key,
726                disable_sudo,
727                ..Default::default()
728            };
729            crate::vps::executar_su_exec(&vps_nome, &comando, config_override, formato, json, opts)
730                .await
731        }
732        Comando::Scp { acao } => {
733            let (
734                password,
735                password_stdin,
736                key,
737                key_passphrase,
738                key_passphrase_stdin,
739                timeout,
740                json_local,
741            ) = match &acao {
742                AcaoScp::Upload {
743                    password,
744                    password_stdin,
745                    key,
746                    key_passphrase,
747                    key_passphrase_stdin,
748                    timeout,
749                    json,
750                    ..
751                }
752                | AcaoScp::Download {
753                    password,
754                    password_stdin,
755                    key,
756                    key_passphrase,
757                    key_passphrase_stdin,
758                    timeout,
759                    json,
760                    ..
761                } => (
762                    password.clone(),
763                    *password_stdin,
764                    key.clone(),
765                    key_passphrase.clone(),
766                    *key_passphrase_stdin,
767                    *timeout,
768                    *json,
769                ),
770            };
771            let password = ler_stdin_se(password_stdin, password)?;
772            let key_passphrase = ler_stdin_se(key_passphrase_stdin, key_passphrase)?;
773            // GAP-SSH-IO-007b: --json local ou --format json global → envelope de erro JSON.
774            let json_efetivo = json_local || formato == FormatoSaida::Json;
775            if json_efetivo {
776                crate::output::definir_json_erros(true);
777            }
778            crate::scp::executar_scp(
779                acao,
780                config_override,
781                crate::scp::OpcoesScp {
782                    password,
783                    key,
784                    key_passphrase,
785                    timeout,
786                    replace_host_key,
787                    json: json_efetivo,
788                },
789            )
790            .await
791        }
792        Comando::Tunnel {
793            vps_nome,
794            porta_local,
795            host_remoto,
796            porta_remota,
797            timeout_ms,
798            password,
799            key,
800            json,
801        } => {
802            // GAP-SSH-IO-008: --json local ou --format json global.
803            let json_efetivo = json || formato == FormatoSaida::Json;
804            if json_efetivo {
805                crate::output::definir_json_erros(true);
806            }
807            crate::tunnel::executar_tunnel(
808                &vps_nome,
809                porta_local,
810                &host_remoto,
811                porta_remota,
812                config_override,
813                password,
814                key,
815                timeout_ms,
816                replace_host_key,
817                json_efetivo,
818            )
819            .await
820        }
821        Comando::HealthCheck {
822            vps_nome,
823            json,
824            password,
825            timeout,
826        } => {
827            crate::vps::executar_health_check(
828                vps_nome.as_deref(),
829                config_override,
830                formato,
831                json,
832                password,
833                timeout,
834            )
835            .await
836        }
837        Comando::Secrets { acao } => {
838            crate::vps::executar_comando_secrets(acao, config_override, formato).await
839        }
840        Comando::Completions { shell } => {
841            gerar_completions(shell);
842            Ok(())
843        }
844    }
845}
846
847#[cfg(test)]
848mod testes {
849    use super::*;
850    use clap::Parser;
851
852    #[test]
853    fn parser_entende_tunnel_com_timeout() {
854        let args = Argumentos::try_parse_from([
855            "ssh-cli",
856            "tunnel",
857            "vps-a",
858            "8080",
859            "127.0.0.1",
860            "5432",
861            "--timeout-ms",
862            "5000",
863            "--json",
864        ])
865        .expect("tunnel");
866        match args.comando {
867            Comando::Tunnel {
868                timeout_ms,
869                porta_local,
870                json,
871                ..
872            } => {
873                assert_eq!(timeout_ms, 5000);
874                assert_eq!(porta_local, 8080);
875                assert!(json);
876            }
877            _ => panic!("esperado tunnel"),
878        }
879    }
880
881    #[test]
882    fn parser_vps_add_key() {
883        let args = Argumentos::try_parse_from([
884            "ssh-cli",
885            "vps",
886            "add",
887            "--name",
888            "x",
889            "--host",
890            "h",
891            "--user",
892            "u",
893            "--key",
894            "/tmp/id_ed25519",
895        ])
896        .expect("add key");
897        match args.comando {
898            Comando::Vps {
899                acao: AcaoVps::Add { key, password, .. },
900            } => {
901                assert_eq!(key.as_deref(), Some("/tmp/id_ed25519"));
902                assert!(password.is_none());
903            }
904            _ => panic!("esperado add"),
905        }
906    }
907
908    #[test]
909    fn parser_sudo_exec_description() {
910        let args = Argumentos::try_parse_from([
911            "ssh-cli",
912            "sudo-exec",
913            "v",
914            "id",
915            "--description",
916            "who am i",
917        ])
918        .unwrap();
919        match args.comando {
920            Comando::SudoExec { description, .. } => {
921                assert_eq!(description.as_deref(), Some("who am i"));
922            }
923            _ => panic!("sudo-exec"),
924        }
925    }
926
927    #[test]
928    fn parser_su_exec() {
929        let args = Argumentos::try_parse_from(["ssh-cli", "su-exec", "v", "whoami"]).unwrap();
930        assert!(matches!(args.comando, Comando::SuExec { .. }));
931    }
932
933    #[test]
934    fn parser_disable_sudo_global() {
935        let args =
936            Argumentos::try_parse_from(["ssh-cli", "--disable-sudo", "vps", "path"]).unwrap();
937        assert!(args.disable_sudo);
938    }
939
940    #[test]
941    fn parser_doctor() {
942        let args = Argumentos::try_parse_from(["ssh-cli", "vps", "doctor", "--json"]).unwrap();
943        match args.comando {
944            Comando::Vps {
945                acao: AcaoVps::Doctor { json },
946            } => assert!(json),
947            _ => panic!("doctor"),
948        }
949    }
950}