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