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 "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 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 "kubectl logs",
169 "kubectl exec -it",
170 "kubectl exec -ti",
171 "kubectl attach",
172 "kubectl port-forward",
173 "kubectl proxy",
174 "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 "less",
192 "more",
193 "vim",
194 "nvim",
195 "vi ",
196 "nano",
197 "micro ",
198 "helix ",
199 "hx ",
200 "emacs",
201 "tmux",
203 "screen",
204 "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 "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 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
383fn 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 "-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: ®ex::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}