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