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