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