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 "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 tee_hint = match cfg.tee_mode {
113 crate::core::config::TeeMode::Always => {
114 crate::shell::save_tee(&cmd_clone, &output)
115 .map(|p| format!("\n[full output: {p}]"))
116 .unwrap_or_default()
117 }
118 crate::core::config::TeeMode::Failures
119 if !output.trim().is_empty()
120 && (output.contains("error")
121 || output.contains("Error")
122 || output.contains("ERROR")) =>
123 {
124 crate::shell::save_tee(&cmd_clone, &output)
125 .map(|p| format!("\n[full output: {p}]"))
126 .unwrap_or_default()
127 }
128 _ => String::new(),
129 };
130
131 (result, original, saved, tee_hint)
132 };
133
134 let mode = if bypass {
135 Some("bypass".to_string())
136 } else if raw {
137 Some("raw".to_string())
138 } else {
139 None
140 };
141
142 let shell_mismatch = if cfg!(windows) && !raw {
143 shell_mismatch_hint(&command, &result_out)
144 } else {
145 String::new()
146 };
147
148 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
149 let final_out = format!("{result_out}{tee_hint}{shell_mismatch}");
150
151 Ok(ToolOutput {
152 text: final_out,
153 original_tokens: original,
154 saved_tokens: saved,
155 mode,
156 path: None,
157 changed: false,
158 })
159 })
160 }
161}
162
163#[allow(clippy::fn_params_excessive_bools)]
164fn resolve_shell_raw_flags(
165 arg_raw: bool,
166 arg_bypass: bool,
167 env_disabled: bool,
168 env_raw: bool,
169) -> (bool, bool) {
170 let bypass = arg_bypass || env_raw;
171 let raw = arg_raw || bypass || env_disabled;
172 (raw, bypass)
173}
174
175fn shell_mismatch_hint(command: &str, output: &str) -> String {
176 let shell = crate::shell::shell_name();
177 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
178 let has_error = output.contains("is not recognized")
179 || output.contains("not found")
180 || output.contains("command not found");
181
182 if !has_error {
183 return String::new();
184 }
185
186 let powershell_cmds = [
187 "Get-Content",
188 "Select-Object",
189 "Get-ChildItem",
190 "Set-Location",
191 "Where-Object",
192 "ForEach-Object",
193 "Select-String",
194 "Invoke-Expression",
195 "Write-Output",
196 ];
197 let uses_powershell = powershell_cmds
198 .iter()
199 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
200
201 if is_posix && uses_powershell {
202 format!(
203 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
204 )
205 } else {
206 String::new()
207 }
208}