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