Skip to main content

lean_ctx/tools/registered/
ctx_shell.rs

1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_bool, get_str, McpTool, ToolContext, ToolOutput};
6use crate::tool_defs::tool_def;
7
8pub struct CtxShellTool;
9
10impl McpTool for CtxShellTool {
11    fn name(&self) -> &'static str {
12        "ctx_shell"
13    }
14
15    fn tool_def(&self) -> Tool {
16        tool_def(
17            "ctx_shell",
18            "Run shell command (compressed output, 95+ patterns). Use raw=true to skip compression. cwd sets working directory (persists across calls via cd tracking). Output redaction is on by default for non-admin roles (admin can disable).",
19            json!({
20                "type": "object",
21                "properties": {
22                    "command": { "type": "string", "description": "Shell command to execute" },
23                    "raw": { "type": "boolean", "description": "Skip compression, return full uncompressed output. Redaction still applies by default for non-admin roles." },
24                    "cwd": { "type": "string", "description": "Working directory for the command. If omitted, uses last cd target or project root." },
25                    "env": { "type": "object", "description": "Additional environment variables to set for the command. Use to pass agent runtime vars (e.g. CODEX_THREAD_ID).", "additionalProperties": { "type": "string" } }
26                },
27                "required": ["command"]
28            }),
29        )
30    }
31
32    fn handle(
33        &self,
34        args: &Map<String, Value>,
35        ctx: &ToolContext,
36    ) -> Result<ToolOutput, ErrorData> {
37        let command = get_str(args, "command")
38            .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
39
40        if let Some(rejection) = crate::tools::ctx_shell::validate_command(&command) {
41            return Ok(ToolOutput::simple(rejection));
42        }
43
44        if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
45            return Ok(ToolOutput::simple(msg));
46        }
47
48        tokio::task::block_in_place(|| {
49            let session_lock = ctx
50                .session
51                .as_ref()
52                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
53
54            let explicit_cwd = get_str(args, "cwd");
55            let effective_cwd = {
56                let session = session_lock.blocking_read();
57                session.effective_cwd(explicit_cwd.as_deref())
58            };
59
60            {
61                let mut session = session_lock.blocking_write();
62                session.update_shell_cwd(&command);
63                let root_missing = session
64                    .project_root
65                    .as_deref()
66                    .is_none_or(|r| r.trim().is_empty());
67                if root_missing {
68                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
69                    if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd) {
70                        if home.as_deref() != Some(root.as_str()) {
71                            session.project_root = Some(root.clone());
72                            crate::core::index_orchestrator::ensure_all_background(&root);
73                        }
74                    }
75                }
76            }
77
78            let arg_raw = get_bool(args, "raw").unwrap_or(false);
79            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
80            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
81            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
82            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
83
84            let crp_mode = ctx.crp_mode;
85            let cmd_clone = command.clone();
86            let cwd_clone = effective_cwd;
87
88            let extra_env: std::collections::HashMap<String, String> = args
89                .get("env")
90                .and_then(|v| v.as_object())
91                .map(|obj| {
92                    obj.iter()
93                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
94                        .collect()
95                })
96                .unwrap_or_default();
97
98            let (output, exit_code) = crate::server::execute::execute_command_with_env(
99                &cmd_clone, &cwd_clone, &extra_env,
100            );
101
102            let (result_out, original, saved, tee_hint) = if raw {
103                let tokens = crate::core::tokens::count_tokens(&output);
104                (output, tokens, 0, String::new())
105            } else {
106                let result = crate::tools::ctx_shell::handle(&cmd_clone, &output, crp_mode);
107                let original = crate::core::tokens::count_tokens(&output);
108                let sent = crate::core::tokens::count_tokens(&result);
109                let saved = original.saturating_sub(sent);
110
111                let cfg = crate::core::config::Config::load();
112                let savings_pct = if original > 0 {
113                    ((original.saturating_sub(sent)) as f64 / original as f64) * 100.0
114                } else {
115                    0.0
116                };
117                let tee_hint = match cfg.tee_mode {
118                    crate::core::config::TeeMode::Always => {
119                        crate::shell::save_tee(&cmd_clone, &output)
120                            .map(|p| format!("\n[full output: {p}]"))
121                            .unwrap_or_default()
122                    }
123                    crate::core::config::TeeMode::Failures
124                        if !output.trim().is_empty()
125                            && (output.contains("error")
126                                || output.contains("Error")
127                                || output.contains("ERROR")) =>
128                    {
129                        crate::shell::save_tee(&cmd_clone, &output)
130                            .map(|p| format!("\n[full output: {p}]"))
131                            .unwrap_or_default()
132                    }
133                    crate::core::config::TeeMode::HighCompression
134                        if savings_pct > 70.0 && original > 100 =>
135                    {
136                        crate::shell::save_tee(&cmd_clone, &output)
137                            .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
138                            .unwrap_or_default()
139                    }
140                    _ => {
141                        if savings_pct > 70.0
142                            && original > 100
143                            && matches!(cfg.tee_mode, crate::core::config::TeeMode::Failures)
144                        {
145                            crate::shell::save_tee(&cmd_clone, &output)
146                                .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
147                                .unwrap_or_default()
148                        } else {
149                            String::new()
150                        }
151                    }
152                };
153
154                (result, original, saved, tee_hint)
155            };
156
157            let mode = if bypass {
158                Some("bypass".to_string())
159            } else if raw {
160                Some("raw".to_string())
161            } else {
162                None
163            };
164
165            let shell_mismatch = if cfg!(windows) && !raw {
166                shell_mismatch_hint(&command, &result_out)
167            } else {
168                String::new()
169            };
170
171            let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
172            let exit_suffix = if exit_code != 0 {
173                format!("\n[exit:{exit_code}]")
174            } else {
175                String::new()
176            };
177            let final_out = format!("{result_out}{tee_hint}{shell_mismatch}{exit_suffix}");
178
179            Ok(ToolOutput {
180                text: final_out,
181                original_tokens: original,
182                saved_tokens: saved,
183                mode,
184                path: None,
185                changed: false,
186            })
187        })
188    }
189}
190
191#[allow(clippy::fn_params_excessive_bools)]
192fn resolve_shell_raw_flags(
193    arg_raw: bool,
194    arg_bypass: bool,
195    env_disabled: bool,
196    env_raw: bool,
197) -> (bool, bool) {
198    let bypass = arg_bypass || env_raw;
199    let raw = arg_raw || bypass || env_disabled;
200    (raw, bypass)
201}
202
203fn shell_mismatch_hint(command: &str, output: &str) -> String {
204    let shell = crate::shell::shell_name();
205    let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
206    let has_error = output.contains("is not recognized")
207        || output.contains("not found")
208        || output.contains("command not found");
209
210    if !has_error {
211        return String::new();
212    }
213
214    let powershell_cmds = [
215        "Get-Content",
216        "Select-Object",
217        "Get-ChildItem",
218        "Set-Location",
219        "Where-Object",
220        "ForEach-Object",
221        "Select-String",
222        "Invoke-Expression",
223        "Write-Output",
224    ];
225    let uses_powershell = powershell_cmds
226        .iter()
227        .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
228
229    if is_posix && uses_powershell {
230        format!(
231            "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
232        )
233    } else {
234        String::new()
235    }
236}