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