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