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                },
26                "required": ["command"]
27            }),
28        )
29    }
30
31    fn handle(
32        &self,
33        args: &Map<String, Value>,
34        ctx: &ToolContext,
35    ) -> Result<ToolOutput, ErrorData> {
36        let command = get_str(args, "command")
37            .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
38
39        if let Some(rejection) = crate::tools::ctx_shell::validate_command(&command) {
40            return Ok(ToolOutput::simple(rejection));
41        }
42
43        if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
44            return Ok(ToolOutput::simple(msg));
45        }
46
47        tokio::task::block_in_place(|| {
48            let session_lock = ctx
49                .session
50                .as_ref()
51                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
52
53            let explicit_cwd = get_str(args, "cwd");
54            let effective_cwd = {
55                let session = session_lock.blocking_read();
56                session.effective_cwd(explicit_cwd.as_deref())
57            };
58
59            {
60                let mut session = session_lock.blocking_write();
61                session.update_shell_cwd(&command);
62                let root_missing = session
63                    .project_root
64                    .as_deref()
65                    .is_none_or(|r| r.trim().is_empty());
66                if root_missing {
67                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
68                    if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd) {
69                        if home.as_deref() != Some(root.as_str()) {
70                            session.project_root = Some(root.clone());
71                            crate::core::index_orchestrator::ensure_all_background(&root);
72                        }
73                    }
74                }
75            }
76
77            let arg_raw = get_bool(args, "raw").unwrap_or(false);
78            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
79            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
80            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
81            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
82
83            let crp_mode = ctx.crp_mode;
84            let cmd_clone = command.clone();
85            let cwd_clone = effective_cwd;
86
87            let (output, _exit_code) =
88                crate::server::execute::execute_command_in(&cmd_clone, &cwd_clone);
89
90            let (result_out, original, saved, tee_hint) = if raw {
91                let tokens = crate::core::tokens::count_tokens(&output);
92                (output, tokens, 0, String::new())
93            } else {
94                let result = crate::tools::ctx_shell::handle(&cmd_clone, &output, crp_mode);
95                let original = crate::core::tokens::count_tokens(&output);
96                let sent = crate::core::tokens::count_tokens(&result);
97                let saved = original.saturating_sub(sent);
98
99                let cfg = crate::core::config::Config::load();
100                let tee_hint = match cfg.tee_mode {
101                    crate::core::config::TeeMode::Always => {
102                        crate::shell::save_tee(&cmd_clone, &output)
103                            .map(|p| format!("\n[full output: {p}]"))
104                            .unwrap_or_default()
105                    }
106                    crate::core::config::TeeMode::Failures
107                        if !output.trim().is_empty()
108                            && (output.contains("error")
109                                || output.contains("Error")
110                                || output.contains("ERROR")) =>
111                    {
112                        crate::shell::save_tee(&cmd_clone, &output)
113                            .map(|p| format!("\n[full output: {p}]"))
114                            .unwrap_or_default()
115                    }
116                    _ => String::new(),
117                };
118
119                (result, original, saved, tee_hint)
120            };
121
122            let mode = if bypass {
123                Some("bypass".to_string())
124            } else if raw {
125                Some("raw".to_string())
126            } else {
127                None
128            };
129
130            let shell_mismatch = if cfg!(windows) && !raw {
131                shell_mismatch_hint(&command, &result_out)
132            } else {
133                String::new()
134            };
135
136            let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
137            let final_out = format!("{result_out}{tee_hint}{shell_mismatch}");
138
139            Ok(ToolOutput {
140                text: final_out,
141                original_tokens: original,
142                saved_tokens: saved,
143                mode,
144                path: None,
145                changed: false,
146            })
147        })
148    }
149}
150
151#[allow(clippy::fn_params_excessive_bools)]
152fn resolve_shell_raw_flags(
153    arg_raw: bool,
154    arg_bypass: bool,
155    env_disabled: bool,
156    env_raw: bool,
157) -> (bool, bool) {
158    let bypass = arg_bypass || env_raw;
159    let raw = arg_raw || bypass || env_disabled;
160    (raw, bypass)
161}
162
163fn shell_mismatch_hint(command: &str, output: &str) -> String {
164    let shell = crate::shell::shell_name();
165    let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
166    let has_error = output.contains("is not recognized")
167        || output.contains("not found")
168        || output.contains("command not found");
169
170    if !has_error {
171        return String::new();
172    }
173
174    let powershell_cmds = [
175        "Get-Content",
176        "Select-Object",
177        "Get-ChildItem",
178        "Set-Location",
179        "Where-Object",
180        "ForEach-Object",
181        "Select-String",
182        "Invoke-Expression",
183        "Write-Output",
184    ];
185    let uses_powershell = powershell_cmds
186        .iter()
187        .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
188
189    if is_posix && uses_powershell {
190        format!(
191            "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
192        )
193    } else {
194        String::new()
195    }
196}