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 {
26 if io::stdout().is_terminal() {
27 return exec_inherit_tracked(command, &shell, &shell_flag);
28 }
29 return exec_inherit(command, &shell, &shell_flag);
30 }
31
32 exec_buffered(command, &shell, &shell_flag, &cfg)
33}
34
35fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
36 let status = Command::new(shell)
37 .arg(shell_flag)
38 .arg(command)
39 .env("LEAN_CTX_ACTIVE", "1")
40 .stdin(Stdio::inherit())
41 .stdout(Stdio::inherit())
42 .stderr(Stdio::inherit())
43 .status();
44
45 match status {
46 Ok(s) => s.code().unwrap_or(1),
47 Err(e) => {
48 eprintln!("lean-ctx: failed to execute: {e}");
49 127
50 }
51 }
52}
53
54fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
55 let code = exec_inherit(command, shell, shell_flag);
56 stats::record(command, 0, 0);
57 code
58}
59
60fn combine_output(stdout: &str, stderr: &str) -> String {
61 if stderr.is_empty() {
62 stdout.to_string()
63 } else if stdout.is_empty() {
64 stderr.to_string()
65 } else {
66 format!("{stdout}\n{stderr}")
67 }
68}
69
70fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
71 let start = std::time::Instant::now();
72
73 let child = Command::new(shell)
74 .arg(shell_flag)
75 .arg(command)
76 .env("LEAN_CTX_ACTIVE", "1")
77 .stdout(Stdio::piped())
78 .stderr(Stdio::piped())
79 .spawn();
80
81 let child = match child {
82 Ok(c) => c,
83 Err(e) => {
84 eprintln!("lean-ctx: failed to execute: {e}");
85 return 127;
86 }
87 };
88
89 let output = match child.wait_with_output() {
90 Ok(o) => o,
91 Err(e) => {
92 eprintln!("lean-ctx: failed to wait: {e}");
93 return 127;
94 }
95 };
96
97 let duration_ms = start.elapsed().as_millis();
98 let exit_code = output.status.code().unwrap_or(1);
99 let stdout = String::from_utf8_lossy(&output.stdout);
100 let stderr = String::from_utf8_lossy(&output.stderr);
101
102 let full_output = combine_output(&stdout, &stderr);
103 let input_tokens = count_tokens(&full_output);
104
105 let (compressed, output_tokens) = compress_and_measure(command, &stdout, &stderr);
106
107 stats::record(command, input_tokens, output_tokens);
108
109 if !compressed.is_empty() {
110 let _ = io::stdout().write_all(compressed.as_bytes());
111 if !compressed.ends_with('\n') {
112 let _ = io::stdout().write_all(b"\n");
113 }
114 }
115 let should_tee = match cfg.tee_mode {
116 config::TeeMode::Always => !full_output.trim().is_empty(),
117 config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
118 config::TeeMode::Never => false,
119 };
120 if should_tee {
121 if let Some(path) = save_tee(command, &full_output) {
122 eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
123 }
124 }
125
126 let threshold = cfg.slow_command_threshold_ms;
127 if threshold > 0 && duration_ms >= threshold as u128 {
128 slow_log::record(command, duration_ms, exit_code);
129 }
130
131 exit_code
132}
133
134const BUILTIN_PASSTHROUGH: &[&str] = &[
135 "turbo",
137 "nx serve",
138 "nx dev",
139 "next dev",
140 "vite dev",
141 "vite preview",
142 "vitest",
143 "nuxt dev",
144 "astro dev",
145 "webpack serve",
146 "webpack-dev-server",
147 "nodemon",
148 "concurrently",
149 "pm2",
150 "pm2 logs",
151 "gatsby develop",
152 "expo start",
153 "react-scripts start",
154 "ng serve",
155 "remix dev",
156 "wrangler dev",
157 "hugo server",
158 "hugo serve",
159 "jekyll serve",
160 "bun dev",
161 "ember serve",
162 "docker compose up",
164 "docker-compose up",
165 "docker compose logs",
166 "docker-compose logs",
167 "docker compose exec",
168 "docker-compose exec",
169 "docker compose run",
170 "docker-compose run",
171 "docker logs",
172 "docker attach",
173 "docker exec -it",
174 "docker exec -ti",
175 "docker run -it",
176 "docker run -ti",
177 "docker stats",
178 "docker events",
179 "kubectl logs",
181 "kubectl exec -it",
182 "kubectl exec -ti",
183 "kubectl attach",
184 "kubectl port-forward",
185 "kubectl proxy",
186 "top",
188 "htop",
189 "btop",
190 "watch ",
191 "tail -f",
192 "tail -F",
193 "journalctl -f",
194 "journalctl --follow",
195 "dmesg -w",
196 "dmesg --follow",
197 "strace",
198 "tcpdump",
199 "ping ",
200 "ping6 ",
201 "traceroute",
202 "less",
204 "more",
205 "vim",
206 "nvim",
207 "vi ",
208 "nano",
209 "micro ",
210 "helix ",
211 "hx ",
212 "emacs",
213 "tmux",
215 "screen",
216 "ssh ",
218 "telnet ",
219 "nc ",
220 "ncat ",
221 "psql",
222 "mysql",
223 "sqlite3",
224 "redis-cli",
225 "mongosh",
226 "mongo ",
227 "python3 -i",
228 "python -i",
229 "irb",
230 "rails console",
231 "rails c ",
232 "iex",
233 "cargo watch",
235 "az login",
237 "az account",
238 "gh auth",
239 "gcloud auth",
240 "gcloud init",
241 "aws sso",
242 "aws configure sso",
243 "firebase login",
244 "netlify login",
245 "vercel login",
246 "heroku login",
247 "flyctl auth",
248 "fly auth",
249 "railway login",
250 "supabase login",
251 "wrangler login",
252 "doppler login",
253 "vault login",
254 "oc login",
255 "kubelogin",
256 "--use-device-code",
257];
258
259fn is_excluded_command(command: &str, excluded: &[String]) -> bool {
260 let cmd = command.trim().to_lowercase();
261 for pattern in BUILTIN_PASSTHROUGH {
262 if cmd == *pattern || cmd.starts_with(&format!("{pattern} ")) || cmd.contains(pattern) {
263 return true;
264 }
265 }
266 if excluded.is_empty() {
267 return false;
268 }
269 excluded.iter().any(|excl| {
270 let excl_lower = excl.trim().to_lowercase();
271 cmd == excl_lower || cmd.starts_with(&format!("{excl_lower} "))
272 })
273}
274
275pub fn interactive() {
276 let real_shell = detect_shell();
277
278 eprintln!(
279 "lean-ctx shell v{} (wrapping {real_shell})",
280 env!("CARGO_PKG_VERSION")
281 );
282 eprintln!("All command output is automatically compressed.");
283 eprintln!("Type 'exit' to quit.\n");
284
285 let stdin = io::stdin();
286 let mut stdout = io::stdout();
287
288 loop {
289 let _ = write!(stdout, "lean-ctx> ");
290 let _ = stdout.flush();
291
292 let mut line = String::new();
293 match stdin.lock().read_line(&mut line) {
294 Ok(0) => break,
295 Ok(_) => {}
296 Err(_) => break,
297 }
298
299 let cmd = line.trim();
300 if cmd.is_empty() {
301 continue;
302 }
303 if cmd == "exit" || cmd == "quit" {
304 break;
305 }
306 if cmd == "gain" {
307 println!("{}", stats::format_gain());
308 continue;
309 }
310
311 let exit_code = exec(cmd);
312
313 if exit_code != 0 {
314 let _ = writeln!(stdout, "[exit: {exit_code}]");
315 }
316 }
317}
318
319fn compress_and_measure(command: &str, stdout: &str, stderr: &str) -> (String, usize) {
320 let compressed_stdout = compress_if_beneficial(command, stdout);
321 let compressed_stderr = compress_if_beneficial(command, stderr);
322
323 let mut result = String::new();
324 if !compressed_stdout.is_empty() {
325 result.push_str(&compressed_stdout);
326 }
327 if !compressed_stderr.is_empty() {
328 if !result.is_empty() {
329 result.push('\n');
330 }
331 result.push_str(&compressed_stderr);
332 }
333
334 let output_tokens = count_tokens(&result);
335 (result, output_tokens)
336}
337
338fn compress_if_beneficial(command: &str, output: &str) -> String {
339 if output.trim().is_empty() {
340 return String::new();
341 }
342
343 if crate::tools::ctx_shell::contains_auth_flow(output) {
344 return output.to_string();
345 }
346
347 let original_tokens = count_tokens(output);
348
349 if original_tokens < 50 {
350 return output.to_string();
351 }
352
353 let min_output_tokens = 5;
354
355 if let Some(compressed) = patterns::compress_output(command, output) {
356 if !compressed.trim().is_empty() {
357 let compressed_tokens = count_tokens(&compressed);
358 if compressed_tokens >= min_output_tokens && compressed_tokens < original_tokens {
359 let saved = original_tokens - compressed_tokens;
360 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
361 return format!(
362 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
363 );
364 }
365 if compressed_tokens < min_output_tokens {
366 return output.to_string();
367 }
368 }
369 }
370
371 let cleaned = crate::core::compressor::lightweight_cleanup(output);
373 let cleaned_tokens = count_tokens(&cleaned);
374 if cleaned_tokens < original_tokens {
375 let lines: Vec<&str> = cleaned.lines().collect();
376 if lines.len() > 30 {
377 let first = &lines[..5];
378 let last = &lines[lines.len() - 5..];
379 let omitted = lines.len() - 10;
380 let total = lines.len();
381 let compressed = format!(
382 "{}\n[truncated: showing 10/{total} lines, {omitted} omitted]\n{}",
383 first.join("\n"),
384 last.join("\n")
385 );
386 let ct = count_tokens(&compressed);
387 if ct < original_tokens {
388 let saved = original_tokens - ct;
389 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
390 return format!("{compressed}\n[lean-ctx: {original_tokens}→{ct} tok, -{pct}%]");
391 }
392 }
393 if cleaned_tokens < original_tokens {
394 let saved = original_tokens - cleaned_tokens;
395 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
396 return format!(
397 "{cleaned}\n[lean-ctx: {original_tokens}→{cleaned_tokens} tok, -{pct}%]"
398 );
399 }
400 }
401
402 let lines: Vec<&str> = output.lines().collect();
403 if lines.len() > 30 {
404 let first = &lines[..5];
405 let last = &lines[lines.len() - 5..];
406 let omitted = lines.len() - 10;
407 let compressed = format!(
408 "{}\n... ({omitted} lines omitted) ...\n{}",
409 first.join("\n"),
410 last.join("\n")
411 );
412 let compressed_tokens = count_tokens(&compressed);
413 if compressed_tokens < original_tokens {
414 let saved = original_tokens - compressed_tokens;
415 let pct = (saved as f64 / original_tokens as f64 * 100.0).round() as usize;
416 return format!(
417 "{compressed}\n[lean-ctx: {original_tokens}→{compressed_tokens} tok, -{pct}%]"
418 );
419 }
420 }
421
422 output.to_string()
423}
424
425fn windows_shell_flag_for_exe_basename(exe_basename: &str) -> &'static str {
428 if exe_basename.contains("powershell") || exe_basename.contains("pwsh") {
429 "-Command"
430 } else if exe_basename == "cmd.exe" || exe_basename == "cmd" {
431 "/C"
432 } else {
433 "-c"
437 }
438}
439
440pub fn shell_and_flag() -> (String, String) {
441 let shell = detect_shell();
442 let flag = if cfg!(windows) {
443 let name = std::path::Path::new(&shell)
444 .file_name()
445 .and_then(|n| n.to_str())
446 .unwrap_or("")
447 .to_ascii_lowercase();
448 windows_shell_flag_for_exe_basename(&name).to_string()
449 } else {
450 "-c".to_string()
451 };
452 (shell, flag)
453}
454
455fn detect_shell() -> String {
456 if let Ok(shell) = std::env::var("LEAN_CTX_SHELL") {
457 return shell;
458 }
459
460 if let Ok(shell) = std::env::var("SHELL") {
461 let bin = std::path::Path::new(&shell)
462 .file_name()
463 .and_then(|n| n.to_str())
464 .unwrap_or("sh");
465
466 if bin == "lean-ctx" {
467 return find_real_shell();
468 }
469 return shell;
470 }
471
472 find_real_shell()
473}
474
475#[cfg(unix)]
476fn find_real_shell() -> String {
477 for shell in &["/bin/zsh", "/bin/bash", "/bin/sh"] {
478 if std::path::Path::new(shell).exists() {
479 return shell.to_string();
480 }
481 }
482 "/bin/sh".to_string()
483}
484
485#[cfg(windows)]
486fn find_real_shell() -> String {
487 if is_running_in_powershell() {
488 if let Ok(pwsh) = which_powershell() {
489 return pwsh;
490 }
491 }
492 if let Ok(comspec) = std::env::var("COMSPEC") {
493 return comspec;
494 }
495 "cmd.exe".to_string()
496}
497
498#[cfg(windows)]
499fn is_running_in_powershell() -> bool {
500 std::env::var("PSModulePath").is_ok()
501}
502
503#[cfg(windows)]
504fn which_powershell() -> Result<String, ()> {
505 for candidate in &["pwsh.exe", "powershell.exe"] {
506 if let Ok(output) = std::process::Command::new("where").arg(candidate).output() {
507 if output.status.success() {
508 if let Ok(path) = String::from_utf8(output.stdout) {
509 if let Some(first_line) = path.lines().next() {
510 let trimmed = first_line.trim();
511 if !trimmed.is_empty() {
512 return Ok(trimmed.to_string());
513 }
514 }
515 }
516 }
517 }
518 }
519 Err(())
520}
521
522pub fn save_tee(command: &str, output: &str) -> Option<String> {
523 let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
524 std::fs::create_dir_all(&tee_dir).ok()?;
525
526 cleanup_old_tee_logs(&tee_dir);
527
528 let cmd_slug: String = command
529 .chars()
530 .take(40)
531 .map(|c| {
532 if c.is_alphanumeric() || c == '-' {
533 c
534 } else {
535 '_'
536 }
537 })
538 .collect();
539 let ts = chrono::Local::now().format("%Y-%m-%d_%H%M%S");
540 let filename = format!("{ts}_{cmd_slug}.log");
541 let path = tee_dir.join(&filename);
542
543 let masked = mask_sensitive_data(output);
544 std::fs::write(&path, masked).ok()?;
545 Some(path.to_string_lossy().to_string())
546}
547
548fn mask_sensitive_data(input: &str) -> String {
549 use regex::Regex;
550
551 let patterns: Vec<(&str, Regex)> = vec![
552 ("Bearer token", Regex::new(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}").unwrap()),
553 ("Authorization header", Regex::new(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+").unwrap()),
554 ("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()),
555 ("AWS key", Regex::new(r"(AKIA[0-9A-Z]{12,})").unwrap()),
556 ("Private key block", Regex::new(r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)").unwrap()),
557 ("GitHub token", Regex::new(r"(gh[pousr]_)[a-zA-Z0-9]{20,}").unwrap()),
558 ("Generic long hex/base64 secret", Regex::new(r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#).unwrap()),
559 ];
560
561 let mut result = input.to_string();
562 for (label, re) in &patterns {
563 result = re
564 .replace_all(&result, |caps: ®ex::Captures| {
565 if let Some(prefix) = caps.get(1) {
566 format!("{}[REDACTED:{}]", prefix.as_str(), label)
567 } else {
568 format!("[REDACTED:{}]", label)
569 }
570 })
571 .to_string();
572 }
573 result
574}
575
576fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
577 let cutoff =
578 std::time::SystemTime::now().checked_sub(std::time::Duration::from_secs(24 * 60 * 60));
579 let cutoff = match cutoff {
580 Some(t) => t,
581 None => return,
582 };
583
584 if let Ok(entries) = std::fs::read_dir(tee_dir) {
585 for entry in entries.flatten() {
586 if let Ok(meta) = entry.metadata() {
587 if let Ok(modified) = meta.modified() {
588 if modified < cutoff {
589 let _ = std::fs::remove_file(entry.path());
590 }
591 }
592 }
593 }
594 }
595}
596
597#[cfg(test)]
598mod windows_shell_flag_tests {
599 use super::windows_shell_flag_for_exe_basename;
600
601 #[test]
602 fn cmd_uses_slash_c() {
603 assert_eq!(windows_shell_flag_for_exe_basename("cmd.exe"), "/C");
604 assert_eq!(windows_shell_flag_for_exe_basename("cmd"), "/C");
605 }
606
607 #[test]
608 fn powershell_uses_command() {
609 assert_eq!(
610 windows_shell_flag_for_exe_basename("powershell.exe"),
611 "-Command"
612 );
613 assert_eq!(windows_shell_flag_for_exe_basename("pwsh.exe"), "-Command");
614 }
615
616 #[test]
617 fn posix_shells_use_dash_c() {
618 assert_eq!(windows_shell_flag_for_exe_basename("bash.exe"), "-c");
619 assert_eq!(windows_shell_flag_for_exe_basename("bash"), "-c");
620 assert_eq!(windows_shell_flag_for_exe_basename("sh.exe"), "-c");
621 assert_eq!(windows_shell_flag_for_exe_basename("zsh.exe"), "-c");
622 assert_eq!(windows_shell_flag_for_exe_basename("fish.exe"), "-c");
623 }
624}
625
626#[cfg(test)]
627mod passthrough_tests {
628 use super::is_excluded_command;
629
630 #[test]
631 fn turbo_is_passthrough() {
632 assert!(is_excluded_command("turbo run dev", &[]));
633 assert!(is_excluded_command("turbo run build", &[]));
634 assert!(is_excluded_command("pnpm turbo run dev", &[]));
635 assert!(is_excluded_command("npx turbo run dev", &[]));
636 }
637
638 #[test]
639 fn dev_servers_are_passthrough() {
640 assert!(is_excluded_command("next dev", &[]));
641 assert!(is_excluded_command("vite dev", &[]));
642 assert!(is_excluded_command("nuxt dev", &[]));
643 assert!(is_excluded_command("astro dev", &[]));
644 assert!(is_excluded_command("nodemon server.js", &[]));
645 }
646
647 #[test]
648 fn interactive_tools_are_passthrough() {
649 assert!(is_excluded_command("vim file.rs", &[]));
650 assert!(is_excluded_command("nvim", &[]));
651 assert!(is_excluded_command("htop", &[]));
652 assert!(is_excluded_command("ssh user@host", &[]));
653 assert!(is_excluded_command("tail -f /var/log/syslog", &[]));
654 }
655
656 #[test]
657 fn docker_streaming_is_passthrough() {
658 assert!(is_excluded_command("docker logs my-container", &[]));
659 assert!(is_excluded_command("docker logs -f webapp", &[]));
660 assert!(is_excluded_command("docker attach my-container", &[]));
661 assert!(is_excluded_command("docker exec -it web bash", &[]));
662 assert!(is_excluded_command("docker exec -ti web bash", &[]));
663 assert!(is_excluded_command("docker run -it ubuntu bash", &[]));
664 assert!(is_excluded_command("docker compose exec web bash", &[]));
665 assert!(is_excluded_command("docker stats", &[]));
666 assert!(is_excluded_command("docker events", &[]));
667 }
668
669 #[test]
670 fn kubectl_is_passthrough() {
671 assert!(is_excluded_command("kubectl logs my-pod", &[]));
672 assert!(is_excluded_command("kubectl logs -f deploy/web", &[]));
673 assert!(is_excluded_command("kubectl exec -it pod -- bash", &[]));
674 assert!(is_excluded_command(
675 "kubectl port-forward svc/web 8080:80",
676 &[]
677 ));
678 assert!(is_excluded_command("kubectl attach my-pod", &[]));
679 assert!(is_excluded_command("kubectl proxy", &[]));
680 }
681
682 #[test]
683 fn database_repls_are_passthrough() {
684 assert!(is_excluded_command("psql -U user mydb", &[]));
685 assert!(is_excluded_command("mysql -u root -p", &[]));
686 assert!(is_excluded_command("sqlite3 data.db", &[]));
687 assert!(is_excluded_command("redis-cli", &[]));
688 assert!(is_excluded_command("mongosh", &[]));
689 }
690
691 #[test]
692 fn streaming_tools_are_passthrough() {
693 assert!(is_excluded_command("journalctl -f", &[]));
694 assert!(is_excluded_command("ping 8.8.8.8", &[]));
695 assert!(is_excluded_command("strace -p 1234", &[]));
696 assert!(is_excluded_command("tcpdump -i eth0", &[]));
697 assert!(is_excluded_command("tail -F /var/log/app.log", &[]));
698 assert!(is_excluded_command("tmux new -s work", &[]));
699 assert!(is_excluded_command("screen -S dev", &[]));
700 }
701
702 #[test]
703 fn additional_dev_servers_are_passthrough() {
704 assert!(is_excluded_command("gatsby develop", &[]));
705 assert!(is_excluded_command("ng serve --port 4200", &[]));
706 assert!(is_excluded_command("remix dev", &[]));
707 assert!(is_excluded_command("wrangler dev", &[]));
708 assert!(is_excluded_command("hugo server", &[]));
709 assert!(is_excluded_command("bun dev", &[]));
710 assert!(is_excluded_command("cargo watch -x test", &[]));
711 }
712
713 #[test]
714 fn normal_commands_not_excluded() {
715 assert!(!is_excluded_command("git status", &[]));
716 assert!(!is_excluded_command("cargo test", &[]));
717 assert!(!is_excluded_command("npm run build", &[]));
718 assert!(!is_excluded_command("ls -la", &[]));
719 }
720
721 #[test]
722 fn user_exclusions_work() {
723 let excl = vec!["myapp".to_string()];
724 assert!(is_excluded_command("myapp serve", &excl));
725 assert!(!is_excluded_command("git status", &excl));
726 }
727
728 #[test]
729 fn auth_commands_excluded() {
730 assert!(is_excluded_command("az login --use-device-code", &[]));
731 assert!(is_excluded_command("gh auth login", &[]));
732 assert!(is_excluded_command("gcloud auth login", &[]));
733 assert!(is_excluded_command("aws sso login", &[]));
734 assert!(is_excluded_command("firebase login", &[]));
735 assert!(is_excluded_command("vercel login", &[]));
736 assert!(is_excluded_command("heroku login", &[]));
737 assert!(is_excluded_command("az login", &[]));
738 assert!(is_excluded_command("kubelogin convert-kubeconfig", &[]));
739 assert!(is_excluded_command("vault login -method=oidc", &[]));
740 assert!(is_excluded_command("flyctl auth login", &[]));
741 }
742
743 #[test]
744 fn auth_exclusion_does_not_affect_normal_commands() {
745 assert!(!is_excluded_command("git log", &[]));
746 assert!(!is_excluded_command("npm run build", &[]));
747 assert!(!is_excluded_command("cargo test", &[]));
748 assert!(!is_excluded_command("aws s3 ls", &[]));
749 assert!(!is_excluded_command("gcloud compute instances list", &[]));
750 assert!(!is_excluded_command("az vm list", &[]));
751 }
752}