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