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.9.16 (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 let Ok(comspec) = std::env::var("COMSPEC") {
419        return comspec;
420    }
421    "cmd.exe".to_string()
422}
423
424fn save_tee(command: &str, output: &str) -> Option<String> {
425    let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
426    std::fs::create_dir_all(&tee_dir).ok()?;
427
428    cleanup_old_tee_logs(&tee_dir);
429
430    let cmd_slug: String = command
431        .chars()
432        .take(40)
433        .map(|c| {
434            if c.is_alphanumeric() || c == '-' {
435                c
436            } else {
437                '_'
438            }
439        })
440        .collect();
441    let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
442    let filename = format!("{ts}_{cmd_slug}.log");
443    let path = tee_dir.join(&filename);
444
445    let masked = mask_sensitive_data(output);
446    std::fs::write(&path, masked).ok()?;
447    Some(path.to_string_lossy().to_string())
448}
449
450fn mask_sensitive_data(input: &str) -> String {
451    use regex::Regex;
452
453    let patterns: Vec<(&str, Regex)> = vec![
454        ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
455        ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
456        ("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()),
457        ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
458        ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
459        ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
460        ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
461    ];
462
463    let mut result = input.to_string();
464    for (label, re) in &patterns {
465        result = re
466            .replace_all(&result, |caps: &regex::Captures| {
467                if let Some(prefix) = caps.get(1) {
468                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
469                } else {
470                    format!("[REDACTED:{}]", label)
471                }
472            })
473            .to_string();
474    }
475    result
476}
477
478fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
479    let cutoff =
480        std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
481    let cutoff = match cutoff {
482        Some(t) => t,
483        None => return,
484    };
485
486    if let Ok(entries) = std::fs::read_dir(tee_dir) {
487        for entry in entries.flatten() {
488            if let Ok(meta) = entry.metadata() {
489                if let Ok(modified) = meta.modified() {
490                    if modified < cutoff {
491                        let _ = std::fs::remove_file(entry.path());
492                    }
493                }
494            }
495        }
496    }
497}
498
499#[cfg(test)]
500mod windows_shell_flag_tests {
501    use super::windows_shell_flag_for_exe_basename;
502
503    #[test]
504    fn cmd_uses_slash_c() {
505        assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
506        assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
507    }
508
509    #[test]
510    fn powershell_uses_command() {
511        assert_eq!(
512            windows_shell_flag_for_exe_basename("powershell.exe"),
513            "-Command"
514        );
515        assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
516    }
517
518    #[test]
519    fn posix_shells_use_dash_c() {
520        assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
521        assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
522        assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
523        assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
524        assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
525    }
526}
527
528#[cfg(test)]
529mod passthrough_tests {
530    use super::is_excluded_command;
531
532    #[test]
533    fn turbo_is_passthrough() {
534        assert!(is_excluded_command("turbo run dev", &[]));
535        assert!(is_excluded_command("turbo run build", &[]));
536        assert!(is_excluded_command("pnpm turbo run dev", &[]));
537        assert!(is_excluded_command("npx turbo run dev", &[]));
538    }
539
540    #[test]
541    fn dev_servers_are_passthrough() {
542        assert!(is_excluded_command("next dev", &[]));
543        assert!(is_excluded_command("vite dev", &[]));
544        assert!(is_excluded_command("nuxt dev", &[]));
545        assert!(is_excluded_command("astro dev", &[]));
546        assert!(is_excluded_command("nodemon server.js", &[]));
547    }
548
549    #[test]
550    fn interactive_tools_are_passthrough() {
551        assert!(is_excluded_command("vim file.rs", &[]));
552        assert!(is_excluded_command("nvim", &[]));
553        assert!(is_excluded_command("htop", &[]));
554        assert!(is_excluded_command("ssh user@host", &[]));
555        assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
556    }
557
558    #[test]
559    fn docker_streaming_is_passthrough() {
560        assert!(is_excluded_command("docker logs my-container", &[]));
561        assert!(is_excluded_command("docker logs -f webapp", &[]));
562        assert!(is_excluded_command("docker attach my-container", &[]));
563        assert!(is_excluded_command("docker exec -it web bash", &[]));
564        assert!(is_excluded_command("docker exec -ti web bash", &[]));
565        assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
566        assert!(is_excluded_command("docker compose exec web bash", &[]));
567        assert!(is_excluded_command("docker stats", &[]));
568        assert!(is_excluded_command("docker events", &[]));
569    }
570
571    #[test]
572    fn kubectl_is_passthrough() {
573        assert!(is_excluded_command("kubectl logs my-pod", &[]));
574        assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
575        assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
576        assert!(is_excluded_command(
577            "kubectl port-forward svc/web 8080:80",
578            &[]
579        ));
580        assert!(is_excluded_command("kubectl attach my-pod", &[]));
581        assert!(is_excluded_command("kubectl proxy", &[]));
582    }
583
584    #[test]
585    fn database_repls_are_passthrough() {
586        assert!(is_excluded_command("psql -U user mydb", &[]));
587        assert!(is_excluded_command("mysql -u root -p", &[]));
588        assert!(is_excluded_command("sqlite3 data.db", &[]));
589        assert!(is_excluded_command("redis-cli", &[]));
590        assert!(is_excluded_command("mongosh", &[]));
591    }
592
593    #[test]
594    fn streaming_tools_are_passthrough() {
595        assert!(is_excluded_command("journalctl -f", &[]));
596        assert!(is_excluded_command("ping 8.8.8.8", &[]));
597        assert!(is_excluded_command("strace -p 1234", &[]));
598        assert!(is_excluded_command("tcpdump -i eth0", &[]));
599        assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
600        assert!(is_excluded_command("tmux new -s work", &[]));
601        assert!(is_excluded_command("screen -S dev", &[]));
602    }
603
604    #[test]
605    fn additional_dev_servers_are_passthrough() {
606        assert!(is_excluded_command("gatsby develop", &[]));
607        assert!(is_excluded_command("ng serve --port 4200", &[]));
608        assert!(is_excluded_command("remix dev", &[]));
609        assert!(is_excluded_command("wrangler dev", &[]));
610        assert!(is_excluded_command("hugo server", &[]));
611        assert!(is_excluded_command("bun dev", &[]));
612        assert!(is_excluded_command("cargo watch -x test", &[]));
613    }
614
615    #[test]
616    fn normal_commands_not_excluded() {
617        assert!(!is_excluded_command("git status", &[]));
618        assert!(!is_excluded_command("cargo test", &[]));
619        assert!(!is_excluded_command("npm run build", &[]));
620        assert!(!is_excluded_command("ls -la", &[]));
621    }
622
623    #[test]
624    fn user_exclusions_work() {
625        let excl = vec!["myapp".to_string()];
626        assert!(is_excluded_command("myapp serve", &excl));
627        assert!(!is_excluded_command("git status", &excl));
628    }
629}