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