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