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 "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 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 "kubectl logs",
178 "kubectl exec -it",
179 "kubectl exec -ti",
180 "kubectl attach",
181 "kubectl port-forward",
182 "kubectl proxy",
183 "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 "less",
201 "more",
202 "vim",
203 "nvim",
204 "vi ",
205 "nano",
206 "micro ",
207 "helix ",
208 "hx ",
209 "emacs",
210 "tmux",
212 "screen",
213 "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 "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 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
393fn 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 "-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: ®ex::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}