Skip to main content

lean_ctx/shell/
exec.rs

1use std::io::{self, IsTerminal, Write};
2use std::process::{Command, Stdio};
3
4use crate::core::config;
5use crate::core::slow_log;
6use crate::core::tokens::count_tokens;
7
8/// Execute a command from pre-split argv without going through `sh -c`.
9/// Used by `-t` mode when the shell hook passes `"$@"` — arguments are
10/// already correctly split by the user's shell, so re-serializing them
11/// into a string and re-parsing via `sh -c` would risk mangling complex
12/// quoted arguments (em-dashes, `#`, nested quotes, etc.).
13pub fn exec_argv(args: &[String]) -> i32 {
14    if args.is_empty() {
15        return 127;
16    }
17
18    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
19        return exec_direct(args);
20    }
21
22    let joined = super::platform::join_command(args);
23    let cfg = config::Config::load();
24    let policy = super::output_policy::classify(&joined, &cfg.excluded_commands);
25
26    if policy.is_protected() {
27        let code = exec_direct(args);
28        crate::core::tool_lifecycle::record_shell_command(0, 0);
29        return code;
30    }
31
32    let code = exec_direct(args);
33    crate::core::tool_lifecycle::record_shell_command(0, 0);
34    code
35}
36
37fn exec_direct(args: &[String]) -> i32 {
38    let status = Command::new(&args[0])
39        .args(&args[1..])
40        .env("LEAN_CTX_ACTIVE", "1")
41        .stdin(Stdio::inherit())
42        .stdout(Stdio::inherit())
43        .stderr(Stdio::inherit())
44        .status();
45
46    match status {
47        Ok(s) => s.code().unwrap_or(1),
48        Err(e) => {
49            tracing::error!("lean-ctx: failed to execute: {e}");
50            127
51        }
52    }
53}
54
55pub fn exec(command: &str) -> i32 {
56    let (shell, shell_flag) = super::platform::shell_and_flag();
57    let command = crate::tools::ctx_shell::normalize_command_for_shell(command);
58    let command = command.as_str();
59
60    if std::env::var("LEAN_CTX_DISABLED").is_ok() || std::env::var("LEAN_CTX_ACTIVE").is_ok() {
61        return exec_inherit(command, &shell, &shell_flag);
62    }
63
64    let cfg = config::Config::load();
65    let force_compress = std::env::var("LEAN_CTX_COMPRESS").is_ok();
66    let raw_mode = std::env::var("LEAN_CTX_RAW").is_ok();
67
68    if raw_mode {
69        return exec_inherit_tracked(command, &shell, &shell_flag);
70    }
71
72    let policy = super::output_policy::classify(command, &cfg.excluded_commands);
73
74    // Passthrough: ALWAYS bypass compression, even with force_compress.
75    if policy == super::output_policy::OutputPolicy::Passthrough {
76        return exec_inherit_tracked(command, &shell, &shell_flag);
77    }
78
79    // Verbatim: bypass compression unless force_compress is set,
80    // in which case use buffered path (compress_if_beneficial will
81    // respect the verbatim classification and only size-cap).
82    if policy == super::output_policy::OutputPolicy::Verbatim && !force_compress {
83        return exec_inherit_tracked(command, &shell, &shell_flag);
84    }
85
86    if !force_compress {
87        if io::stdout().is_terminal() {
88            return exec_inherit_tracked(command, &shell, &shell_flag);
89        }
90        let code = exec_inherit(command, &shell, &shell_flag);
91        crate::core::tool_lifecycle::record_shell_command(0, 0);
92        return code;
93    }
94
95    exec_buffered(command, &shell, &shell_flag, &cfg)
96}
97
98fn exec_inherit(command: &str, shell: &str, shell_flag: &str) -> i32 {
99    let status = Command::new(shell)
100        .arg(shell_flag)
101        .arg(command)
102        .env("LEAN_CTX_ACTIVE", "1")
103        .stdin(Stdio::inherit())
104        .stdout(Stdio::inherit())
105        .stderr(Stdio::inherit())
106        .status();
107
108    match status {
109        Ok(s) => s.code().unwrap_or(1),
110        Err(e) => {
111            tracing::error!("lean-ctx: failed to execute: {e}");
112            127
113        }
114    }
115}
116
117fn exec_inherit_tracked(command: &str, shell: &str, shell_flag: &str) -> i32 {
118    let code = exec_inherit(command, shell, shell_flag);
119    crate::core::tool_lifecycle::record_shell_command(0, 0);
120    code
121}
122
123fn combine_output(stdout: &str, stderr: &str) -> String {
124    if stderr.is_empty() {
125        stdout.to_string()
126    } else if stdout.is_empty() {
127        stderr.to_string()
128    } else {
129        format!("{stdout}\n{stderr}")
130    }
131}
132
133fn exec_buffered(command: &str, shell: &str, shell_flag: &str, cfg: &config::Config) -> i32 {
134    #[cfg(windows)]
135    super::platform::set_console_utf8();
136
137    let start = std::time::Instant::now();
138
139    let mut cmd = Command::new(shell);
140
141    #[cfg(windows)]
142    let ps_tmp_path: Option<tempfile::TempPath>;
143    #[cfg(windows)]
144    {
145        let is_powershell =
146            shell.to_lowercase().contains("powershell") || shell.to_lowercase().contains("pwsh");
147        if is_powershell {
148            let ps_script = format!(
149                "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {}",
150                command
151            );
152            let tmp = tempfile::Builder::new()
153                .prefix("lean-ctx-ps-")
154                .suffix(".ps1")
155                .tempfile()
156                .expect("failed to create temp file for PowerShell script");
157            let tmp_path = tmp.into_temp_path();
158            let _ = std::fs::write(&tmp_path, &ps_script);
159            cmd.args([
160                "-NoProfile",
161                "-ExecutionPolicy",
162                "Bypass",
163                "-File",
164                &tmp_path.to_string_lossy(),
165            ]);
166            ps_tmp_path = Some(tmp_path);
167        } else {
168            cmd.arg(shell_flag);
169            cmd.arg(command);
170            ps_tmp_path = None;
171        }
172    }
173    #[cfg(not(windows))]
174    {
175        cmd.arg(shell_flag);
176        cmd.arg(command);
177    }
178
179    let child = cmd
180        .env("LEAN_CTX_ACTIVE", "1")
181        .stdout(Stdio::piped())
182        .stderr(Stdio::piped())
183        .spawn();
184
185    let child = match child {
186        Ok(c) => c,
187        Err(e) => {
188            tracing::error!("lean-ctx: failed to execute: {e}");
189            #[cfg(windows)]
190            if let Some(ref tmp) = ps_tmp_path {
191                let _ = std::fs::remove_file(tmp);
192            }
193            return 127;
194        }
195    };
196
197    let output = match child.wait_with_output() {
198        Ok(o) => o,
199        Err(e) => {
200            tracing::error!("lean-ctx: failed to wait: {e}");
201            #[cfg(windows)]
202            if let Some(ref tmp) = ps_tmp_path {
203                let _ = std::fs::remove_file(tmp);
204            }
205            return 127;
206        }
207    };
208
209    let duration_ms = start.elapsed().as_millis();
210    let exit_code = output.status.code().unwrap_or(1);
211    let stdout = super::platform::decode_output(&output.stdout);
212    let stderr = super::platform::decode_output(&output.stderr);
213
214    let full_output = combine_output(&stdout, &stderr);
215    let input_tokens = count_tokens(&full_output);
216
217    let (compressed, output_tokens) =
218        super::compress::compress_and_measure(command, &stdout, &stderr);
219
220    crate::core::tool_lifecycle::record_shell_command(input_tokens, output_tokens);
221
222    if !compressed.is_empty() {
223        let _ = io::stdout().write_all(compressed.as_bytes());
224        if !compressed.ends_with('\n') {
225            let _ = io::stdout().write_all(b"\n");
226        }
227    }
228    let should_tee = match cfg.tee_mode {
229        config::TeeMode::Always => !full_output.trim().is_empty(),
230        config::TeeMode::Failures => exit_code != 0 && !full_output.trim().is_empty(),
231        config::TeeMode::Never => false,
232    };
233    if should_tee {
234        if let Some(path) = super::redact::save_tee(command, &full_output) {
235            if !matches!(std::env::var("LEAN_CTX_QUIET"), Ok(v) if v.trim() == "1") {
236                eprintln!("[lean-ctx: full output -> {path} (redacted, 24h TTL)]");
237            }
238        }
239    }
240
241    let threshold = cfg.slow_command_threshold_ms;
242    if threshold > 0 && duration_ms >= threshold as u128 {
243        slow_log::record(command, duration_ms, exit_code);
244    }
245
246    #[cfg(windows)]
247    if let Some(ref tmp) = ps_tmp_path {
248        let _ = std::fs::remove_file(tmp);
249    }
250
251    exit_code
252}
253
254#[cfg(test)]
255mod exec_tests {
256    #[test]
257    fn exec_direct_runs_true() {
258        let code = super::exec_direct(&["true".to_string()]);
259        assert_eq!(code, 0);
260    }
261
262    #[test]
263    fn exec_direct_runs_false() {
264        let code = super::exec_direct(&["false".to_string()]);
265        assert_ne!(code, 0);
266    }
267
268    #[test]
269    fn exec_direct_preserves_args_with_special_chars() {
270        let code = super::exec_direct(&[
271            "echo".to_string(),
272            "hello world".to_string(),
273            "it's here".to_string(),
274            "a \"quoted\" thing".to_string(),
275        ]);
276        assert_eq!(code, 0);
277    }
278
279    #[test]
280    fn exec_direct_nonexistent_returns_127() {
281        let code = super::exec_direct(&["__nonexistent_binary_12345__".to_string()]);
282        assert_eq!(code, 127);
283    }
284
285    #[test]
286    fn exec_argv_empty_returns_127() {
287        let code = super::exec_argv(&[]);
288        assert_eq!(code, 127);
289    }
290
291    #[test]
292    fn exec_argv_runs_simple_command() {
293        let code = super::exec_argv(&["true".to_string()]);
294        assert_eq!(code, 0);
295    }
296
297    #[test]
298    fn exec_argv_passes_through_when_disabled() {
299        std::env::set_var("LEAN_CTX_DISABLED", "1");
300        let code = super::exec_argv(&["true".to_string()]);
301        std::env::remove_var("LEAN_CTX_DISABLED");
302        assert_eq!(code, 0);
303    }
304}