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];
233
234fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
235    let cmd = command.trim().to_lowercase();
236    for pattern in BUILTIN_PASSTHROUGH {
237        if cmd == *pattern || cmd.starts_with(&format!("{pattern} ")) || cmd.contains(pattern) {
238            return true;
239        }
240    }
241    if excluded.is_empty() {
242        return false;
243    }
244    excluded.iter().any(|excl| {
245        let excl_lower = excl.trim().to_lowercase();
246        cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
247    })
248}
249
250pub fn interactive() {
251    let real_shell = detect_shell();
252
253    eprintln!(
254        "lean-ctx shell v{} (wrapping {real_shell})",
255        env!("CARGO_PKG_VERSION")
256    );
257    eprintln!("All command output is automatically compressed.");
258    eprintln!("Type 'exit' to quit.\n");
259
260    let stdin = io::stdin();
261    let mut stdout = io::stdout();
262
263    loop {
264        let _ = write!(stdout, "lean-ctx> ");
265        let _ = stdout.flush();
266
267        let mut line = String::new();
268        match stdin.lock().read_line(&mut line) {
269            Ok(0) => break,
270            Ok(_) => {}
271            Err(_) => break,
272        }
273
274        let cmd = line.trim();
275        if cmd.is_empty() {
276            continue;
277        }
278        if cmd == "exit" || cmd == "quit" {
279            break;
280        }
281        if cmd == "gain" {
282            println!("{}", stats::format_gain());
283            continue;
284        }
285
286        let exit_code = exec(cmd);
287
288        if exit_code != 0 {
289            let _ = writeln!(stdout, "[exit: {exit_code}]");
290        }
291    }
292}
293
294fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
295    let compressed_stdout = compress_if_beneficial(command, stdout);
296    let compressed_stderr = compress_if_beneficial(command, stderr);
297
298    let mut result = String::new();
299    if !compressed_stdout.is_empty() {
300        result.push_str(&compressed_stdout);
301    }
302    if !compressed_stderr.is_empty() {
303        if !result.is_empty() {
304            result.push('\n');
305        }
306        result.push_str(&compressed_stderr);
307    }
308
309    let output_tokens = count_tokens(&result);
310    (result, output_tokens)
311}
312
313fn compress_if_beneficial(command: &str, output: &str) -> String {
314    if output.trim().is_empty() {
315        return String::new();
316    }
317
318    let original_tokens = count_tokens(output);
319
320    if original_tokens < 50 {
321        return output.to_string();
322    }
323
324    let min_output_tokens = 5;
325
326    if let Some(compressed) = patterns::compress_output(command, output) {
327        if !compressed.trim().is_empty() {
328            let compressed_tokens = count_tokens(&compressed);
329            if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
330                let saved = original_tokens - compressed_tokens;
331                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
332                return format!(
333                    "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
334                );
335            }
336            if compressed_tokens < min_output_tokens {
337                return output.to_string();
338            }
339        }
340    }
341
342    // Apply lightweight cleanup to remove whitespace-only lines and collapse braces
343    let cleaned = crate::core::compressor::lightweight_cleanup(output);
344    let cleaned_tokens = count_tokens(&cleaned);
345    if cleaned_tokens < original_tokens {
346        let lines: Vec<&str> = cleaned.lines().collect();
347        if lines.len() > 30 {
348            let first = &lines[..5];
349            let last = &lines[lines.len() - 5..];
350            let omitted = lines.len() - 10;
351            let total = lines.len();
352            let compressed = format!(
353                "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
354                first.join("\n"),
355                last.join("\n")
356            );
357            let ct = count_tokens(&compressed);
358            if ct < original_tokens {
359                let saved = original_tokens - ct;
360                let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
361                return format!("{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]");
362            }
363        }
364        if cleaned_tokens < original_tokens {
365            let saved = original_tokens - cleaned_tokens;
366            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
367            return format!(
368                "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
369            );
370        }
371    }
372
373    let lines: Vec<&str> = output.lines().collect();
374    if lines.len() > 30 {
375        let first = &lines[..5];
376        let last = &lines[lines.len() - 5..];
377        let omitted = lines.len() - 10;
378        let compressed = format!(
379            "{}\n... ({omitted} lines omitted) ...\n{}",
380            first.join("\n"),
381            last.join("\n")
382        );
383        let compressed_tokens = count_tokens(&compressed);
384        if compressed_tokens < original_tokens {
385            let saved = original_tokens - compressed_tokens;
386            let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
387            return format!(
388                "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
389            );
390        }
391    }
392
393    output.to_string()
394}
395
396/// Windows only: argument that passes one command string to the shell binary.
397/// `exe_basename` must already be ASCII-lowercase (e.g. `bash.exe`, `cmd.exe`).
398fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
399    if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
400        "-Command"
401    } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
402        "/C"
403    } else {
404        // POSIX-style shells: Git Bash / MSYS (`bash`, `sh`, `zsh`, `fish`, …).
405        // `/C` is only valid for `cmd.exe`; using it with bash produced
406        // `/C: Is a directory` and exit 126 (see github.com/yvgude/lean-ctx/issues/7).
407        "-c"
408    }
409}
410
411pub fn shell_and_flag() -> (String, String) {
412    let shell = detect_shell();
413    let flag = if cfg!(windows) {
414        let name = std::path::Path::new(&shell)
415            .file_name()
416            .and_then(|n| n.to_str())
417            .unwrap_or("")
418            .to_ascii_lowercase();
419        windows_shell_flag_for_exe_basename(&name).to_string()
420    } else {
421        "-c".to_string()
422    };
423    (shell, flag)
424}
425
426fn detect_shell() -> String {
427    if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
428        return shell;
429    }
430
431    if let Ok(shell) = std::env::var("SHELL") {
432        let bin = std::path::Path::new(&shell)
433            .file_name()
434            .and_then(|n| n.to_str())
435            .unwrap_or("sh");
436
437        if bin == "lean-ctx" {
438            return find_real_shell();
439        }
440        return shell;
441    }
442
443    find_real_shell()
444}
445
446#[cfg(unix)]
447fn find_real_shell() -> String {
448    for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
449        if std::path::Path::new(shell).exists() {
450            return shell.to_string();
451        }
452    }
453    "/bin/sh".to_string()
454}
455
456#[cfg(windows)]
457fn find_real_shell() -> String {
458    if is_running_in_powershell() {
459        if let Ok(pwsh) = which_powershell() {
460            return pwsh;
461        }
462    }
463    if let Ok(comspec) = std::env::var("COMSPEC") {
464        return comspec;
465    }
466    "cmd.exe".to_string()
467}
468
469#[cfg(windows)]
470fn is_running_in_powershell() -> bool {
471    std::env::var("PSModulePath").is_ok()
472}
473
474#[cfg(windows)]
475fn which_powershell() -> Result<String, ()> {
476    for candidate in &["pwsh.exe", "powershell.exe"] {
477        if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
478            if output.status.success() {
479                if let Ok(path) = String::from_utf8(output.stdout) {
480                    if let Some(first_line) = path.lines().next() {
481                        let trimmed = first_line.trim();
482                        if !trimmed.is_empty() {
483                            return Ok(trimmed.to_string());
484                        }
485                    }
486                }
487            }
488        }
489    }
490    Err(())
491}
492
493pub fn save_tee(command: &str, output: &str) -> Option<String> {
494    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
495    std::fs::create_dir_all(&tee_dir).ok()?;
496
497    cleanup_old_tee_logs(&tee_dir);
498
499    let cmd_slug: String = command
500        .chars()
501        .take(40)
502        .map(|c| {
503            if c.is_alphanumeric() || c == '-' {
504                c
505            } else {
506                '_'
507            }
508        })
509        .collect();
510    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
511    let filename = format!("{ts}_{cmd_slug}.log");
512    let path = tee_dir.join(&filename);
513
514    let masked = mask_sensitive_data(output);
515    std::fs::write(&path, masked).ok()?;
516    Some(path.to_string_lossy().to_string())
517}
518
519fn mask_sensitive_data(input: &str) -> String {
520    use regex::Regex;
521
522    let patterns: Vec<(&str, Regex)> = vec![
523        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
524        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
525        ("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()),
526        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
527        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
528        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
529        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
530    ];
531
532    let mut result = input.to_string();
533    for (label, re) in &patterns {
534        result = re
535            .replace_all(&result, |caps: &regex::Captures| {
536                if let Some(prefix) = caps.get(1) {
537                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
538                } else {
539                    format!("[REDACTED:{}]", label)
540                }
541            })
542            .to_string();
543    }
544    result
545}
546
547fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
548    let cutoff =
549        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
550    let cutoff = match cutoff {
551        Some(t) => t,
552        None => return,
553    };
554
555    if let Ok(entries) = std::fs::read_dir(tee_dir) {
556        for entry in entries.flatten() {
557            if let Ok(meta) = entry.metadata() {
558                if let Ok(modified) = meta.modified() {
559                    if modified < cutoff {
560                        let _ = std::fs::remove_file(entry.path());
561                    }
562                }
563            }
564        }
565    }
566}
567
568#[cfg(test)]
569mod windows_shell_flag_tests {
570    use super::windows_shell_flag_for_exe_basename;
571
572    #[test]
573    fn cmd_uses_slash_c() {
574        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
575        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
576    }
577
578    #[test]
579    fn powershell_uses_command() {
580        assert_eq!(
581            windows_shell_flag_for_exe_basename("powershell.exe"),
582            "-Command"
583        );
584        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
585    }
586
587    #[test]
588    fn posix_shells_use_dash_c() {
589        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
590        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
591        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
592        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
593        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
594    }
595}
596
597#[cfg(test)]
598mod passthrough_tests {
599    use super::is_excluded_command;
600
601    #[test]
602    fn turbo_is_passthrough() {
603        assert!(is_excluded_command("turbo run dev", &[]));
604        assert!(is_excluded_command("turbo run build", &[]));
605        assert!(is_excluded_command("pnpm turbo run dev", &[]));
606        assert!(is_excluded_command("npx turbo run dev", &[]));
607    }
608
609    #[test]
610    fn dev_servers_are_passthrough() {
611        assert!(is_excluded_command("next dev", &[]));
612        assert!(is_excluded_command("vite dev", &[]));
613        assert!(is_excluded_command("nuxt dev", &[]));
614        assert!(is_excluded_command("astro dev", &[]));
615        assert!(is_excluded_command("nodemon server.js", &[]));
616    }
617
618    #[test]
619    fn interactive_tools_are_passthrough() {
620        assert!(is_excluded_command("vim file.rs", &[]));
621        assert!(is_excluded_command("nvim", &[]));
622        assert!(is_excluded_command("htop", &[]));
623        assert!(is_excluded_command("ssh user@host", &[]));
624        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
625    }
626
627    #[test]
628    fn docker_streaming_is_passthrough() {
629        assert!(is_excluded_command("docker logs my-container", &[]));
630        assert!(is_excluded_command("docker logs -f webapp", &[]));
631        assert!(is_excluded_command("docker attach my-container", &[]));
632        assert!(is_excluded_command("docker exec -it web bash", &[]));
633        assert!(is_excluded_command("docker exec -ti web bash", &[]));
634        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
635        assert!(is_excluded_command("docker compose exec web bash", &[]));
636        assert!(is_excluded_command("docker stats", &[]));
637        assert!(is_excluded_command("docker events", &[]));
638    }
639
640    #[test]
641    fn kubectl_is_passthrough() {
642        assert!(is_excluded_command("kubectl logs my-pod", &[]));
643        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
644        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
645        assert!(is_excluded_command(
646            "kubectl port-forward svc/web 8080:80",
647            &[]
648        ));
649        assert!(is_excluded_command("kubectl attach my-pod", &[]));
650        assert!(is_excluded_command("kubectl proxy", &[]));
651    }
652
653    #[test]
654    fn database_repls_are_passthrough() {
655        assert!(is_excluded_command("psql -U user mydb", &[]));
656        assert!(is_excluded_command("mysql -u root -p", &[]));
657        assert!(is_excluded_command("sqlite3 data.db", &[]));
658        assert!(is_excluded_command("redis-cli", &[]));
659        assert!(is_excluded_command("mongosh", &[]));
660    }
661
662    #[test]
663    fn streaming_tools_are_passthrough() {
664        assert!(is_excluded_command("journalctl -f", &[]));
665        assert!(is_excluded_command("ping 8.8.8.8", &[]));
666        assert!(is_excluded_command("strace -p 1234", &[]));
667        assert!(is_excluded_command("tcpdump -i eth0", &[]));
668        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
669        assert!(is_excluded_command("tmux new -s work", &[]));
670        assert!(is_excluded_command("screen -S dev", &[]));
671    }
672
673    #[test]
674    fn additional_dev_servers_are_passthrough() {
675        assert!(is_excluded_command("gatsby develop", &[]));
676        assert!(is_excluded_command("ng serve --port 4200", &[]));
677        assert!(is_excluded_command("remix dev", &[]));
678        assert!(is_excluded_command("wrangler dev", &[]));
679        assert!(is_excluded_command("hugo server", &[]));
680        assert!(is_excluded_command("bun dev", &[]));
681        assert!(is_excluded_command("cargo watch -x test", &[]));
682    }
683
684    #[test]
685    fn normal_commands_not_excluded() {
686        assert!(!is_excluded_command("git status", &[]));
687        assert!(!is_excluded_command("cargo test", &[]));
688        assert!(!is_excluded_command("npm run build", &[]));
689        assert!(!is_excluded_command("ls -la", &[]));
690    }
691
692    #[test]
693    fn user_exclusions_work() {
694        let excl = vec!["myapp".to_string()];
695        assert!(is_excluded_command("myapp serve", &excl));
696        assert!(!is_excluded_command("git status", &excl));
697    }
698}