Skip to main content

lean_ctx/
shell.rs

1use std::io::{self, BufRead, IsTerminal, Write};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5use crate::core::patterns;
6use crate::core::slow_log;
7use crate::core::stats;
8use crate::core::tokens::count_tokens;
9
10pub fn exec(command: &str) -> i32 {
11    let (shell, shell_flag) = shell_and_flag();
12    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
13    let command = command.as_str();
14
15    if std::env::var("LEAN_CTX_DISABLED").is_ok() {
16        return exec_inherit(command, &shell, &shell_flag);
17    }
18
19    let cfg = config::Config::load();
20    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
21    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
22
23    if raw_mode || (!force_compress && is_excluded_command(command, &cfg.excluded_commands)) {
24        return exec_inherit(command, &shell, &shell_flag);
25    }
26
27    if !force_compress {
28        if io::stdout().is_terminal() {
29            return exec_inherit_tracked(command, &shell, &shell_flag);
30        }
31        return exec_inherit(command, &shell, &shell_flag);
32    }
33
34    exec_buffered(command, &shell, &shell_flag, &cfg)
35}
36
37fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
38    let status = Command::new(shell)
39        .arg(shell_flag)
40        .arg(command)
41        .env("LEAN_CTX_ACTIVE", "1")
42        .stdin(Stdio::inherit())
43        .stdout(Stdio::inherit())
44        .stderr(Stdio::inherit())
45        .status();
46
47    match status {
48        Ok(s) => s.code().unwrap_or(1),
49        Err(e) => {
50            eprintln!("lean-ctx: failed to execute: {e}");
51            127
52        }
53    }
54}
55
56fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
57    let code = exec_inherit(command, shell, shell_flag);
58    stats::record(command, 0, 0);
59    code
60}
61
62fn combine_output(stdout: &str, stderr: &str) -> String {
63    if stderr.is_empty() {
64        stdout.to_string()
65    } else if stdout.is_empty() {
66        stderr.to_string()
67    } else {
68        format!("{stdout}\n{stderr}")
69    }
70}
71
72fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
73    let start = std::time::Instant::now();
74
75    let child = Command::new(shell)
76        .arg(shell_flag)
77        .arg(command)
78        .env("LEAN_CTX_ACTIVE", "1")
79        .stdout(Stdio::piped())
80        .stderr(Stdio::piped())
81        .spawn();
82
83    let child = match child {
84        Ok(c) => c,
85        Err(e) => {
86            eprintln!("lean-ctx: failed to execute: {e}");
87            return 127;
88        }
89    };
90
91    let output = match child.wait_with_output() {
92        Ok(o) => o,
93        Err(e) => {
94            eprintln!("lean-ctx: failed to wait: {e}");
95            return 127;
96        }
97    };
98
99    let duration_ms = start.elapsed().as_millis();
100    let exit_code = output.status.code().unwrap_or(1);
101    let stdout = String::from_utf8_lossy(&output.stdout);
102    let stderr = String::from_utf8_lossy(&output.stderr);
103
104    let full_output = combine_output(&stdout, &stderr);
105    let input_tokens = count_tokens(&full_output);
106
107    let (compressed, output_tokens) = compress_and_measure(command, &stdout, &stderr);
108
109    stats::record(command, input_tokens, output_tokens);
110
111    if !compressed.is_empty() {
112        let _ = io::stdout().write_all(compressed.as_bytes());
113        if !compressed.ends_with('\n') {
114            let _ = io::stdout().write_all(b"\n");
115        }
116    }
117    let should_tee = match cfg.tee_mode {
118        config::TeeMode::Always => !full_output.trim().is_empty(),
119        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
120        config::TeeMode::Never => false,
121    };
122    if should_tee {
123        if let Some(path) = save_tee(command, &full_output) {
124            eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
125        }
126    }
127
128    let threshold = cfg.slow_command_threshold_ms;
129    if threshold > 0 && duration_ms >= threshold as u128 {
130        slow_log::record(command, duration_ms, exit_code);
131    }
132
133    exit_code
134}
135
136const BUILTIN_PASSTHROUGH: &[&str] = &[
137    // JS/TS dev servers & watchers
138    "turbo",
139    "nx serve",
140    "nx dev",
141    "next dev",
142    "vite dev",
143    "vite preview",
144    "vitest",
145    "nuxt dev",
146    "astro dev",
147    "webpack serve",
148    "webpack-dev-server",
149    "nodemon",
150    "concurrently",
151    "pm2",
152    "pm2 logs",
153    "gatsby develop",
154    "expo start",
155    "react-scripts start",
156    "ng serve",
157    "remix dev",
158    "wrangler dev",
159    "hugo server",
160    "hugo serve",
161    "jekyll serve",
162    "bun dev",
163    "ember serve",
164    // Docker
165    "docker compose up",
166    "docker-compose up",
167    "docker compose logs",
168    "docker-compose logs",
169    "docker compose exec",
170    "docker-compose exec",
171    "docker compose run",
172    "docker-compose run",
173    "docker logs",
174    "docker attach",
175    "docker exec -it",
176    "docker exec -ti",
177    "docker run -it",
178    "docker run -ti",
179    "docker stats",
180    "docker events",
181    // Kubernetes
182    "kubectl logs",
183    "kubectl exec -it",
184    "kubectl exec -ti",
185    "kubectl attach",
186    "kubectl port-forward",
187    "kubectl proxy",
188    // System monitors & streaming
189    "top",
190    "htop",
191    "btop",
192    "watch ",
193    "tail -f",
194    "tail -F",
195    "journalctl -f",
196    "journalctl --follow",
197    "dmesg -w",
198    "dmesg --follow",
199    "strace",
200    "tcpdump",
201    "ping ",
202    "ping6 ",
203    "traceroute",
204    // Editors & pagers
205    "less",
206    "more",
207    "vim",
208    "nvim",
209    "vi ",
210    "nano",
211    "micro ",
212    "helix ",
213    "hx ",
214    "emacs",
215    // Terminal multiplexers
216    "tmux",
217    "screen",
218    // Interactive shells & REPLs
219    "ssh ",
220    "telnet ",
221    "nc ",
222    "ncat ",
223    "psql",
224    "mysql",
225    "sqlite3",
226    "redis-cli",
227    "mongosh",
228    "mongo ",
229    "python3 -i",
230    "python -i",
231    "irb",
232    "rails console",
233    "rails c ",
234    "iex",
235    // Rust watchers
236    "cargo watch",
237    // Authentication flows (device code, OAuth, SSO — output contains codes users must see)
238    "az login",
239    "az account",
240    "gh auth",
241    "gcloud auth",
242    "gcloud init",
243    "aws sso",
244    "aws configure sso",
245    "firebase login",
246    "netlify login",
247    "vercel login",
248    "heroku login",
249    "flyctl auth",
250    "fly auth",
251    "railway login",
252    "supabase login",
253    "wrangler login",
254    "doppler login",
255    "vault login",
256    "oc login",
257    "kubelogin",
258    "--use-device-code",
259];
260
261fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
262    let cmd = command.trim().to_lowercase();
263    for pattern in BUILTIN_PASSTHROUGH {
264        if cmd == *pattern || cmd.starts_with(&format!("{pattern} ")) || cmd.contains(pattern) {
265            return true;
266        }
267    }
268    if excluded.is_empty() {
269        return false;
270    }
271    excluded.iter().any(|excl| {
272        let excl_lower = excl.trim().to_lowercase();
273        cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
274    })
275}
276
277pub fn interactive() {
278    let real_shell = detect_shell();
279
280    eprintln!(
281        "lean-ctx shell v{} (wrapping {real_shell})",
282        env!("CARGO_PKG_VERSION")
283    );
284    eprintln!("All command output is automatically compressed.");
285    eprintln!("Type 'exit' to quit.\n");
286
287    let stdin = io::stdin();
288    let mut stdout = io::stdout();
289
290    loop {
291        let _ = write!(stdout, "lean-ctx> ");
292        let _ = stdout.flush();
293
294        let mut line = String::new();
295        match stdin.lock().read_line(&mut line) {
296            Ok(0) => break,
297            Ok(_) => {}
298            Err(_) => break,
299        }
300
301        let cmd = line.trim();
302        if cmd.is_empty() {
303            continue;
304        }
305        if cmd == "exit" || cmd == "quit" {
306            break;
307        }
308        if cmd == "gain" {
309            println!("{}", stats::format_gain());
310            continue;
311        }
312
313        let exit_code = exec(cmd);
314
315        if exit_code != 0 {
316            let _ = writeln!(stdout, "[exit: {exit_code}]");
317        }
318    }
319}
320
321fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
322    let compressed_stdout = compress_if_beneficial(command, stdout);
323    let compressed_stderr = compress_if_beneficial(command, stderr);
324
325    let mut result = String::new();
326    if !compressed_stdout.is_empty() {
327        result.push_str(&compressed_stdout);
328    }
329    if !compressed_stderr.is_empty() {
330        if !result.is_empty() {
331            result.push('\n');
332        }
333        result.push_str(&compressed_stderr);
334    }
335
336    let output_tokens = count_tokens(&result);
337    (result, output_tokens)
338}
339
340fn compress_if_beneficial(command: &str, output: &str) -> String {
341    if output.trim().is_empty() {
342        return String::new();
343    }
344
345    if crate::tools::ctx_shell::contains_auth_flow(output) {
346        return output.to_string();
347    }
348
349    let original_tokens = count_tokens(output);
350
351    if original_tokens < 50 {
352        return output.to_string();
353    }
354
355    let min_output_tokens = 5;
356
357    if let Some(compressed) = patterns::compress_output(command, output) {
358        if !compressed.trim().is_empty() {
359            let compressed_tokens = count_tokens(&compressed);
360            if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
361                let saved = original_tokens - compressed_tokens;
362                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
363                return format!(
364                    "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
365                );
366            }
367            if compressed_tokens < min_output_tokens {
368                return output.to_string();
369            }
370        }
371    }
372
373    // Apply lightweight cleanup to remove whitespace-only lines and collapse braces
374    let cleaned = crate::core::compressor::lightweight_cleanup(output);
375    let cleaned_tokens = count_tokens(&cleaned);
376    if cleaned_tokens < original_tokens {
377        let lines: Vec<&str> = cleaned.lines().collect();
378        if lines.len() > 30 {
379            let first = &lines[..5];
380            let last = &lines[lines.len() - 5..];
381            let omitted = lines.len() - 10;
382            let total = lines.len();
383            let compressed = format!(
384                "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
385                first.join("\n"),
386                last.join("\n")
387            );
388            let ct = count_tokens(&compressed);
389            if ct < original_tokens {
390                let saved = original_tokens - ct;
391                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
392                return format!("{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]");
393            }
394        }
395        if cleaned_tokens < original_tokens {
396            let saved = original_tokens - cleaned_tokens;
397            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
398            return format!(
399                "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
400            );
401        }
402    }
403
404    let lines: Vec<&str> = output.lines().collect();
405    if lines.len() > 30 {
406        let first = &lines[..5];
407        let last = &lines[lines.len() - 5..];
408        let omitted = lines.len() - 10;
409        let compressed = format!(
410            "{}\n... ({omitted} lines omitted) ...\n{}",
411            first.join("\n"),
412            last.join("\n")
413        );
414        let compressed_tokens = count_tokens(&compressed);
415        if compressed_tokens < original_tokens {
416            let saved = original_tokens - compressed_tokens;
417            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
418            return format!(
419                "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
420            );
421        }
422    }
423
424    output.to_string()
425}
426
427/// Windows only: argument that passes one command string to the shell binary.
428/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
429fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
430    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
431        "-Command"
432    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
433        "/C"
434    } else {
435        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
436        // `/C` is only valid for `cmd.exe`; using it with bash produced
437        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
438        "-c"
439    }
440}
441
442pub fn shell_and_flag() -> (String, String) {
443    let shell = detect_shell();
444    let flag = if cfg!(windows) {
445        let name = std::path::Path::new(&shell)
446            .file_name()
447            .and_then(|n| n.to_str())
448            .unwrap_or("")
449            .to_ascii_lowercase();
450        windows_shell_flag_for_exe_basename(&name).to_string()
451    } else {
452        "-c".to_string()
453    };
454    (shell, flag)
455}
456
457fn detect_shell() -> String {
458    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
459        return shell;
460    }
461
462    if let Ok(shell) = std::env::var("SHELL") {
463        let bin = std::path::Path::new(&shell)
464            .file_name()
465            .and_then(|n| n.to_str())
466            .unwrap_or("sh");
467
468        if bin == "lean-ctx" {
469            return find_real_shell();
470        }
471        return shell;
472    }
473
474    find_real_shell()
475}
476
477#[cfg(unix)]
478fn find_real_shell() -> String {
479    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
480        if std::path::Path::new(shell).exists() {
481            return shell.to_string();
482        }
483    }
484    "/bin/sh".to_string()
485}
486
487#[cfg(windows)]
488fn find_real_shell() -> String {
489    if is_running_in_powershell() {
490        if let Ok(pwsh) = which_powershell() {
491            return pwsh;
492        }
493    }
494    if let Ok(comspec) = std::env::var("COMSPEC") {
495        return comspec;
496    }
497    "cmd.exe".to_string()
498}
499
500#[cfg(windows)]
501fn is_running_in_powershell() -> bool {
502    std::env::var("PSModulePath").is_ok()
503}
504
505#[cfg(windows)]
506fn which_powershell() -> Result<String, ()> {
507    for candidate in &["pwsh.exe", "powershell.exe"] {
508        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
509            if output.status.success() {
510                if let Ok(path) = String::from_utf8(output.stdout) {
511                    if let Some(first_line) = path.lines().next() {
512                        let trimmed = first_line.trim();
513                        if !trimmed.is_empty() {
514                            return Ok(trimmed.to_string());
515                        }
516                    }
517                }
518            }
519        }
520    }
521    Err(())
522}
523
524pub fn save_tee(command: &str, output: &str) -> Option<String> {
525    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
526    std::fs::create_dir_all(&tee_dir).ok()?;
527
528    cleanup_old_tee_logs(&tee_dir);
529
530    let cmd_slug: String = command
531        .chars()
532        .take(40)
533        .map(|c| {
534            if c.is_alphanumeric() || c == '-' {
535                c
536            } else {
537                '_'
538            }
539        })
540        .collect();
541    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
542    let filename = format!("{ts}_{cmd_slug}.log");
543    let path = tee_dir.join(&filename);
544
545    let masked = mask_sensitive_data(output);
546    std::fs::write(&path, masked).ok()?;
547    Some(path.to_string_lossy().to_string())
548}
549
550fn mask_sensitive_data(input: &str) -> String {
551    use regex::Regex;
552
553    let patterns: Vec<(&str, Regex)> = vec![
554        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
555        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
556        ("API key param", Regex::new(r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#).unwrap()),
557        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
558        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
559        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
560        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
561    ];
562
563    let mut result = input.to_string();
564    for (label, re) in &patterns {
565        result = re
566            .replace_all(&result, |caps: &regex::Captures| {
567                if let Some(prefix) = caps.get(1) {
568                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
569                } else {
570                    format!("[REDACTED:{}]", label)
571                }
572            })
573            .to_string();
574    }
575    result
576}
577
578fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
579    let cutoff =
580        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
581    let cutoff = match cutoff {
582        Some(t) => t,
583        None => return,
584    };
585
586    if let Ok(entries) = std::fs::read_dir(tee_dir) {
587        for entry in entries.flatten() {
588            if let Ok(meta) = entry.metadata() {
589                if let Ok(modified) = meta.modified() {
590                    if modified < cutoff {
591                        let _ = std::fs::remove_file(entry.path());
592                    }
593                }
594            }
595        }
596    }
597}
598
599#[cfg(test)]
600mod windows_shell_flag_tests {
601    use super::windows_shell_flag_for_exe_basename;
602
603    #[test]
604    fn cmd_uses_slash_c() {
605        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
606        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
607    }
608
609    #[test]
610    fn powershell_uses_command() {
611        assert_eq!(
612            windows_shell_flag_for_exe_basename("powershell.exe"),
613            "-Command"
614        );
615        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
616    }
617
618    #[test]
619    fn posix_shells_use_dash_c() {
620        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
621        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
622        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
623        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
624        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
625    }
626}
627
628#[cfg(test)]
629mod passthrough_tests {
630    use super::is_excluded_command;
631
632    #[test]
633    fn turbo_is_passthrough() {
634        assert!(is_excluded_command("turbo run dev", &[]));
635        assert!(is_excluded_command("turbo run build", &[]));
636        assert!(is_excluded_command("pnpm turbo run dev", &[]));
637        assert!(is_excluded_command("npx turbo run dev", &[]));
638    }
639
640    #[test]
641    fn dev_servers_are_passthrough() {
642        assert!(is_excluded_command("next dev", &[]));
643        assert!(is_excluded_command("vite dev", &[]));
644        assert!(is_excluded_command("nuxt dev", &[]));
645        assert!(is_excluded_command("astro dev", &[]));
646        assert!(is_excluded_command("nodemon server.js", &[]));
647    }
648
649    #[test]
650    fn interactive_tools_are_passthrough() {
651        assert!(is_excluded_command("vim file.rs", &[]));
652        assert!(is_excluded_command("nvim", &[]));
653        assert!(is_excluded_command("htop", &[]));
654        assert!(is_excluded_command("ssh user@host", &[]));
655        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
656    }
657
658    #[test]
659    fn docker_streaming_is_passthrough() {
660        assert!(is_excluded_command("docker logs my-container", &[]));
661        assert!(is_excluded_command("docker logs -f webapp", &[]));
662        assert!(is_excluded_command("docker attach my-container", &[]));
663        assert!(is_excluded_command("docker exec -it web bash", &[]));
664        assert!(is_excluded_command("docker exec -ti web bash", &[]));
665        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
666        assert!(is_excluded_command("docker compose exec web bash", &[]));
667        assert!(is_excluded_command("docker stats", &[]));
668        assert!(is_excluded_command("docker events", &[]));
669    }
670
671    #[test]
672    fn kubectl_is_passthrough() {
673        assert!(is_excluded_command("kubectl logs my-pod", &[]));
674        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
675        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
676        assert!(is_excluded_command(
677            "kubectl port-forward svc/web 8080:80",
678            &[]
679        ));
680        assert!(is_excluded_command("kubectl attach my-pod", &[]));
681        assert!(is_excluded_command("kubectl proxy", &[]));
682    }
683
684    #[test]
685    fn database_repls_are_passthrough() {
686        assert!(is_excluded_command("psql -U user mydb", &[]));
687        assert!(is_excluded_command("mysql -u root -p", &[]));
688        assert!(is_excluded_command("sqlite3 data.db", &[]));
689        assert!(is_excluded_command("redis-cli", &[]));
690        assert!(is_excluded_command("mongosh", &[]));
691    }
692
693    #[test]
694    fn streaming_tools_are_passthrough() {
695        assert!(is_excluded_command("journalctl -f", &[]));
696        assert!(is_excluded_command("ping 8.8.8.8", &[]));
697        assert!(is_excluded_command("strace -p 1234", &[]));
698        assert!(is_excluded_command("tcpdump -i eth0", &[]));
699        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
700        assert!(is_excluded_command("tmux new -s work", &[]));
701        assert!(is_excluded_command("screen -S dev", &[]));
702    }
703
704    #[test]
705    fn additional_dev_servers_are_passthrough() {
706        assert!(is_excluded_command("gatsby develop", &[]));
707        assert!(is_excluded_command("ng serve --port 4200", &[]));
708        assert!(is_excluded_command("remix dev", &[]));
709        assert!(is_excluded_command("wrangler dev", &[]));
710        assert!(is_excluded_command("hugo server", &[]));
711        assert!(is_excluded_command("bun dev", &[]));
712        assert!(is_excluded_command("cargo watch -x test", &[]));
713    }
714
715    #[test]
716    fn normal_commands_not_excluded() {
717        assert!(!is_excluded_command("git status", &[]));
718        assert!(!is_excluded_command("cargo test", &[]));
719        assert!(!is_excluded_command("npm run build", &[]));
720        assert!(!is_excluded_command("ls -la", &[]));
721    }
722
723    #[test]
724    fn user_exclusions_work() {
725        let excl = vec!["myapp".to_string()];
726        assert!(is_excluded_command("myapp serve", &excl));
727        assert!(!is_excluded_command("git status", &excl));
728    }
729
730    #[test]
731    fn auth_commands_excluded() {
732        assert!(is_excluded_command("az login --use-device-code", &[]));
733        assert!(is_excluded_command("gh auth login", &[]));
734        assert!(is_excluded_command("gcloud auth login", &[]));
735        assert!(is_excluded_command("aws sso login", &[]));
736        assert!(is_excluded_command("firebase login", &[]));
737        assert!(is_excluded_command("vercel login", &[]));
738        assert!(is_excluded_command("heroku login", &[]));
739        assert!(is_excluded_command("az login", &[]));
740        assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
741        assert!(is_excluded_command("vault login -method=oidc", &[]));
742        assert!(is_excluded_command("flyctl auth login", &[]));
743    }
744
745    #[test]
746    fn auth_exclusion_does_not_affect_normal_commands() {
747        assert!(!is_excluded_command("git log", &[]));
748        assert!(!is_excluded_command("npm run build", &[]));
749        assert!(!is_excluded_command("cargo test", &[]));
750        assert!(!is_excluded_command("aws s3 ls", &[]));
751        assert!(!is_excluded_command("gcloud compute instances list", &[]));
752        assert!(!is_excluded_command("az vm list", &[]));
753    }
754}