Files
mcp/dev_mcp/tools/shell/command_executor.py
2025-03-23 22:18:10 -05:00

1002 lines
36 KiB
Python

"""Command executor tools for MCP Claude Code.
This module provides tools for executing shell commands and scripts with
comprehensive error handling, permissions checking, and progress tracking.
"""
import asyncio
import base64
import os
import shlex
import sys
import tempfile
from collections.abc import Awaitable, Callable
from typing import final
from mcp.server.fastmcp import Context as MCPContext
from mcp.server.fastmcp import FastMCP
from dev_mcp.tools.common.context import create_tool_context
from dev_mcp.tools.common.permissions import PermissionManager
@final
class CommandResult:
"""Represents the result of a command execution."""
def __init__(
self,
return_code: int = 0,
stdout: str = "",
stderr: str = "",
error_message: str | None = None,
):
"""Initialize a command result.
Args:
return_code: The command's return code (0 for success)
stdout: Standard output from the command
stderr: Standard error from the command
error_message: Optional error message for failure cases
"""
self.return_code: int = return_code
self.stdout: str = stdout
self.stderr: str = stderr
self.error_message: str | None = error_message
@property
def is_success(self) -> bool:
"""Check if the command executed successfully.
Returns:
True if the command succeeded, False otherwise
"""
return self.return_code == 0
def format_output(self, include_exit_code: bool = True) -> str:
"""Format the command output as a string.
Args:
include_exit_code: Whether to include the exit code in the output
Returns:
Formatted output string
"""
result_parts: list[str] = []
# Add error message if present
if self.error_message:
result_parts.append(f"Error: {self.error_message}")
# Add exit code if requested and not zero (for non-errors)
if include_exit_code and (self.return_code != 0 or not self.error_message):
result_parts.append(f"Exit code: {self.return_code}")
# Add stdout if present
if self.stdout:
result_parts.append(f"STDOUT:\n{self.stdout}")
# Add stderr if present
if self.stderr:
result_parts.append(f"STDERR:\n{self.stderr}")
# Join with newlines
return "\n\n".join(result_parts)
@final
class CommandExecutor:
"""Command executor tools for MCP Claude Code.
This class provides tools for executing shell commands and scripts with
comprehensive error handling, permissions checking, and progress tracking.
"""
def __init__(
self, permission_manager: PermissionManager, verbose: bool = False
) -> None:
"""Initialize command execution.
Args:
permission_manager: Permission manager for access control
verbose: Enable verbose logging
"""
self.permission_manager: PermissionManager = permission_manager
self.verbose: bool = verbose
# Excluded commands or patterns
self.excluded_commands: list[str] = ["rm"]
# Map of supported interpreters with special handling
self.special_interpreters: dict[
str,
Callable[
[str, str, str | None, dict[str, str] | None, float | None],
Awaitable[CommandResult],
],
] = {
"fish": self._handle_fish_script,
}
def allow_command(self, command: str) -> None:
"""Allow a specific command that might otherwise be excluded.
Args:
command: The command to allow
"""
if command in self.excluded_commands:
self.excluded_commands.remove(command)
def deny_command(self, command: str) -> None:
"""Deny a specific command, adding it to the excluded list.
Args:
command: The command to deny
"""
if command not in self.excluded_commands:
self.excluded_commands.append(command)
def _log(self, message: str, data: object = None) -> None:
"""Log a message if verbose logging is enabled.
Args:
message: The message to log
data: Optional data to include with the message
"""
if not self.verbose:
return
if data is not None:
try:
import json
if isinstance(data, (dict, list)):
data_str = json.dumps(data)
else:
data_str = str(data)
print(f"DEBUG: {message}: {data_str}", file=sys.stderr)
except Exception:
print(f"DEBUG: {message}: {data}", file=sys.stderr)
else:
print(f"DEBUG: {message}", file=sys.stderr)
def is_command_allowed(self, command: str) -> bool:
"""Check if a command is allowed based on exclusion lists.
Args:
command: The command to check
Returns:
True if the command is allowed, False otherwise
"""
# Check for empty commands
try:
args: list[str] = shlex.split(command)
except ValueError as e:
self._log(f"Command parsing error: {e}")
return False
if not args:
return False
base_command: str = args[0]
# Check if base command is in exclusion list
if base_command in self.excluded_commands:
self._log(f"Command rejected (in exclusion list): {base_command}")
return False
return True
async def execute_command(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float | None = 60.0,
use_login_shell: bool = True,
) -> CommandResult:
"""Execute a shell command with safety checks.
Args:
command: The command to execute
cwd: Optional working directory
env: Optional environment variables
timeout: Optional timeout in seconds
use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
Returns:
CommandResult containing execution results
"""
self._log(f"Executing command: {command}")
# Check if the command is allowed
if not self.is_command_allowed(command):
return CommandResult(
return_code=1, error_message=f"Command not allowed: {command}"
)
# Check working directory permissions if specified
if cwd:
if not os.path.isdir(cwd):
return CommandResult(
return_code=1,
error_message=f"Working directory does not exist: {cwd}",
)
if not self.permission_manager.is_path_allowed(cwd):
return CommandResult(
return_code=1, error_message=f"Working directory not allowed: {cwd}"
)
# Set up environment
command_env: dict[str, str] = os.environ.copy()
if env:
command_env.update(env)
try:
# Check if command uses shell features like &&, ||, |, etc. or $ for env vars
shell_operators = ["&&", "||", "|", ";", ">", "<", "$(", "`", "$"]
needs_shell = any(op in command for op in shell_operators)
if needs_shell or use_login_shell:
# Determine which shell to use
shell_cmd = command
if use_login_shell:
# Get the user's login shell
user_shell = os.environ.get("SHELL", "/bin/bash")
shell_basename = os.path.basename(user_shell)
self._log(f"Using login shell: {user_shell}")
# Wrap command with appropriate shell invocation
if shell_basename == "zsh":
shell_cmd = f"{user_shell} -l -c '{command}'"
elif shell_basename == "bash":
shell_cmd = f"{user_shell} -l -c '{command}'"
elif shell_basename == "fish":
shell_cmd = f"{user_shell} -l -c '{command}'"
else:
# Default fallback
shell_cmd = f"{user_shell} -c '{command}'"
else:
self._log(
f"Using shell for command with shell operators: {command}"
)
# Use shell for command execution
process = await asyncio.create_subprocess_shell(
shell_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
else:
# Split the command into arguments for regular commands
args: list[str] = shlex.split(command)
# Create and run the process without shell
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
# Wait for the process to complete with timeout
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
return CommandResult(
return_code=process.returncode or 0,
stdout=stdout_bytes.decode("utf-8", errors="replace"),
stderr=stderr_bytes.decode("utf-8", errors="replace"),
)
except asyncio.TimeoutError:
# Kill the process if it times out
try:
process.kill()
except ProcessLookupError:
pass # Process already terminated
return CommandResult(
return_code=-1,
error_message=f"Command timed out after {timeout} seconds: {command}",
)
except Exception as e:
self._log(f"Command execution error: {str(e)}")
return CommandResult(
return_code=1, error_message=f"Error executing command: {str(e)}"
)
async def execute_script(
self,
script: str,
interpreter: str = "bash",
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float | None = 60.0,
use_login_shell: bool = True,
) -> CommandResult:
"""Execute a script with the specified interpreter.
Args:
script: The script content to execute
interpreter: The interpreter to use (bash, python, etc.)
cwd: Optional working directory
env: Optional environment variables
timeout: Optional timeout in seconds
Returns:
CommandResult containing execution results
"""
self._log(f"Executing script with interpreter: {interpreter}")
# Check working directory permissions if specified
if cwd:
if not os.path.isdir(cwd):
return CommandResult(
return_code=1,
error_message=f"Working directory does not exist: {cwd}",
)
if not self.permission_manager.is_path_allowed(cwd):
return CommandResult(
return_code=1, error_message=f"Working directory not allowed: {cwd}"
)
# Check if we need special handling for this interpreter
interpreter_name = interpreter.split()[0].lower()
if interpreter_name in self.special_interpreters:
self._log(f"Using special handler for interpreter: {interpreter_name}")
special_handler = self.special_interpreters[interpreter_name]
return await special_handler(interpreter, script, cwd, env, timeout)
# Regular execution
return await self._execute_script_with_stdin(
interpreter, script, cwd, env, timeout, use_login_shell
)
async def _execute_script_with_stdin(
self,
interpreter: str,
script: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float | None = 60.0,
use_login_shell: bool = True,
) -> CommandResult:
"""Execute a script by passing it to stdin of the interpreter.
Args:
interpreter: The interpreter command
script: The script content
cwd: Optional working directory
env: Optional environment variables
timeout: Optional timeout in seconds
Returns:
CommandResult containing execution results
"""
# Set up environment
command_env: dict[str, str] = os.environ.copy()
if env:
command_env.update(env)
try:
# Determine if we should use a login shell
if use_login_shell:
# Get the user's login shell
user_shell = os.environ.get("SHELL", "/bin/bash")
os.path.basename(user_shell)
self._log(f"Using login shell for interpreter: {user_shell}")
# Create command that pipes script to interpreter through login shell
shell_cmd = f"{user_shell} -l -c '{interpreter}'"
# Create and run the process with shell
process = await asyncio.create_subprocess_shell(
shell_cmd,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
else:
# Parse the interpreter command to get arguments
interpreter_parts = shlex.split(interpreter)
# Create and run the process normally
process = await asyncio.create_subprocess_exec(
*interpreter_parts,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
# Wait for the process to complete with timeout
try:
script_bytes: bytes = script.encode("utf-8")
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(script_bytes), timeout=timeout
)
return CommandResult(
return_code=process.returncode or 0,
stdout=stdout_bytes.decode("utf-8", errors="replace"),
stderr=stderr_bytes.decode("utf-8", errors="replace"),
)
except asyncio.TimeoutError:
# Kill the process if it times out
try:
process.kill()
except ProcessLookupError:
pass # Process already terminated
return CommandResult(
return_code=-1,
error_message=f"Script execution timed out after {timeout} seconds",
)
except Exception as e:
self._log(f"Script execution error: {str(e)}")
return CommandResult(
return_code=1, error_message=f"Error executing script: {str(e)}"
)
async def _handle_fish_script(
self,
interpreter: str,
script: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float | None = 60.0,
) -> CommandResult:
"""Special handler for Fish shell scripts.
The Fish shell has issues with piped input in some contexts, so we use
a workaround that base64 encodes the script and decodes it in the pipeline.
Args:
interpreter: The fish interpreter command
script: The fish script content
cwd: Optional working directory
env: Optional environment variables
timeout: Optional timeout in seconds
Returns:
CommandResult containing execution results
"""
self._log("Using Fish shell workaround")
# Set up environment
command_env: dict[str, str] = os.environ.copy()
if env:
command_env.update(env)
try:
# Base64 encode the script to avoid stdin issues with Fish
base64_script = base64.b64encode(script.encode("utf-8")).decode("utf-8")
# Create a command that decodes the script and pipes it to fish
command = f'{interpreter} -c "echo {base64_script} | base64 -d | fish"'
self._log(f"Fish command: {command}")
# Create and run the process
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
# Wait for the process to complete with timeout
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
return CommandResult(
return_code=process.returncode or 0,
stdout=stdout_bytes.decode("utf-8", errors="replace"),
stderr=stderr_bytes.decode("utf-8", errors="replace"),
)
except asyncio.TimeoutError:
# Kill the process if it times out
try:
process.kill()
except ProcessLookupError:
pass # Process already terminated
return CommandResult(
return_code=-1,
error_message=f"Fish script execution timed out after {timeout} seconds",
)
except Exception as e:
self._log(f"Fish script execution error: {str(e)}")
return CommandResult(
return_code=1, error_message=f"Error executing Fish script: {str(e)}"
)
async def execute_script_from_file(
self,
script: str,
language: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: float | None = 60.0,
args: list[str] | None = None,
use_login_shell: bool = True,
) -> CommandResult:
"""Execute a script by writing it to a temporary file and executing it.
This is useful for languages where the script is too complex or long
to pass via stdin, or for languages that have limitations with stdin.
Args:
script: The script content
language: The script language (determines file extension and interpreter)
cwd: Optional working directory
env: Optional environment variables
timeout: Optional timeout in seconds
args: Optional command-line arguments
use_login_shell: Whether to use login shell. default true (loads ~/.zshrc, ~/.bashrc, etc.)
Returns:
CommandResult containing execution results
"""
# Language to interpreter mapping
language_map: dict[str, dict[str, str]] = {
"python": {
"command": "python",
"extension": ".py",
},
"javascript": {
"command": "node",
"extension": ".js",
},
"typescript": {
"command": "ts-node",
"extension": ".ts",
},
"bash": {
"command": "bash",
"extension": ".sh",
},
"fish": {
"command": "fish",
"extension": ".fish",
},
"ruby": {
"command": "ruby",
"extension": ".rb",
},
"php": {
"command": "php",
"extension": ".php",
},
"perl": {
"command": "perl",
"extension": ".pl",
},
"r": {
"command": "Rscript",
"extension": ".R",
},
}
# Check if the language is supported
if language not in language_map:
return CommandResult(
return_code=1,
error_message=f"Unsupported language: {language}. Supported languages: {', '.join(language_map.keys())}",
)
# Get language info
language_info = language_map[language]
command = language_info["command"]
extension = language_info["extension"]
# Set up environment
command_env: dict[str, str] = os.environ.copy()
if env:
command_env.update(env)
# Create a temporary file for the script
with tempfile.NamedTemporaryFile(
suffix=extension, mode="w", delete=False
) as temp:
temp_path = temp.name
_ = temp.write(script) # Explicitly ignore the return value
try:
# Determine if we should use a login shell
if use_login_shell:
# Get the user's login shell
user_shell = os.environ.get("SHELL", "/bin/bash")
os.path.basename(user_shell)
self._log(f"Using login shell for script execution: {user_shell}")
# Build the command including args
cmd = f"{command} {temp_path}"
if args:
cmd += " " + " ".join(args)
# Create command that runs script through login shell
shell_cmd = f"{user_shell} -l -c '{cmd}'"
self._log(f"Executing script from file with login shell: {shell_cmd}")
# Create and run the process with shell
process = await asyncio.create_subprocess_shell(
shell_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
else:
# Build command arguments
cmd_args = [command, temp_path]
if args:
cmd_args.extend(args)
self._log(f"Executing script from file with: {' '.join(cmd_args)}")
# Create and run the process normally
process = await asyncio.create_subprocess_exec(
*cmd_args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=command_env,
)
# Wait for the process to complete with timeout
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
return CommandResult(
return_code=process.returncode or 0,
stdout=stdout_bytes.decode("utf-8", errors="replace"),
stderr=stderr_bytes.decode("utf-8", errors="replace"),
)
except asyncio.TimeoutError:
# Kill the process if it times out
try:
process.kill()
except ProcessLookupError:
pass # Process already terminated
return CommandResult(
return_code=-1,
error_message=f"Script execution timed out after {timeout} seconds",
)
except Exception as e:
self._log(f"Script file execution error: {str(e)}")
return CommandResult(
return_code=1, error_message=f"Error executing script: {str(e)}"
)
finally:
# Clean up temporary file
try:
os.unlink(temp_path)
except Exception as e:
self._log(f"Error cleaning up temporary file: {str(e)}")
def get_available_languages(self) -> list[str]:
"""Get a list of available script languages.
Returns:
List of supported language names
"""
# Use the same language map as in execute_script_from_file method
language_map = {
"python": {"command": "python", "extension": ".py"},
"javascript": {"command": "node", "extension": ".js"},
"typescript": {"command": "ts-node", "extension": ".ts"},
"bash": {"command": "bash", "extension": ".sh"},
"fish": {"command": "fish", "extension": ".fish"},
"ruby": {"command": "ruby", "extension": ".rb"},
"php": {"command": "php", "extension": ".php"},
"perl": {"command": "perl", "extension": ".pl"},
"r": {"command": "Rscript", "extension": ".R"},
}
return list(language_map.keys())
def register_tools(self, mcp_server: FastMCP) -> None:
"""Register command execution tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
# Run Command Tool
@mcp_server.tool()
async def run_command(
command: str,
cwd: str,
ctx: MCPContext,
use_login_shell: bool = True,
) -> str:
"""Execute a shell command.
Args:
command: The shell command to execute
cwd: Working directory for the command
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
Returns:
The output of the command
"""
tool_ctx = create_tool_context(ctx)
tool_ctx.set_tool_info("run_command")
await tool_ctx.info(f"Executing command: {command}")
# Check if command is allowed
if not self.is_command_allowed(command):
await tool_ctx.error(f"Command not allowed: {command}")
return f"Error: Command not allowed: {command}"
# Validate required cwd parameter
if not cwd:
await tool_ctx.error("Parameter 'cwd' is required but was None")
return "Error: Parameter 'cwd' is required but was None"
if cwd.strip() == "":
await tool_ctx.error("Parameter 'cwd' cannot be empty")
return "Error: Parameter 'cwd' cannot be empty"
# Check if working directory is allowed
if not self.permission_manager.is_path_allowed(cwd):
await tool_ctx.error(f"Working directory not allowed: {cwd}")
return f"Error: Working directory not allowed: {cwd}"
# Check if working directory exists
if not os.path.isdir(cwd):
await tool_ctx.error(f"Working directory does not exist: {cwd}")
return f"Error: Working directory does not exist: {cwd}"
# Execute the command
result: CommandResult = await self.execute_command(
command, cwd=cwd, timeout=30.0, use_login_shell=use_login_shell
)
# Report result
if result.is_success:
await tool_ctx.info("Command executed successfully")
else:
await tool_ctx.error(
f"Command failed with exit code {result.return_code}"
)
# Format the result
if result.is_success:
# For successful commands, just return stdout unless stderr has content
if result.stderr:
return f"Command executed successfully.\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
return result.stdout
else:
# For failed commands, include all available information
return result.format_output()
# Run Script Tool
@mcp_server.tool()
async def run_script(
script: str,
cwd: str,
ctx: MCPContext,
interpreter: str = "bash",
use_login_shell: bool = True,
) -> str:
"""Execute a script with the specified interpreter.
Args:
script: The script content to execute
cwd: Working directory for script execution
interpreter: The interpreter to use (bash, python, etc.)
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
Returns:
The output of the script
"""
tool_ctx = create_tool_context(ctx)
tool_ctx.set_tool_info("run_script")
# Validate script parameter
if not script:
await tool_ctx.error("Parameter 'script' is required but was None")
return "Error: Parameter 'script' is required but was None"
if script.strip() == "":
await tool_ctx.error("Parameter 'script' cannot be empty")
return "Error: Parameter 'script' cannot be empty"
# interpreter can be None safely as it has a default value
if not interpreter:
interpreter = "bash" # Use default if None
elif interpreter.strip() == "":
await tool_ctx.error("Parameter 'interpreter' cannot be empty")
return "Error: Parameter 'interpreter' cannot be empty"
# Validate required cwd parameter
if not cwd:
await tool_ctx.error("Parameter 'cwd' is required but was None")
return "Error: Parameter 'cwd' is required but was None"
if cwd.strip() == "":
await tool_ctx.error("Parameter 'cwd' cannot be empty")
return "Error: Parameter 'cwd' cannot be empty"
await tool_ctx.info(f"Executing script with interpreter: {interpreter}")
# Validate required cwd parameter
if not cwd:
await tool_ctx.error("Parameter 'cwd' is required but was None")
return "Error: Parameter 'cwd' is required but was None"
if cwd.strip() == "":
await tool_ctx.error("Parameter 'cwd' cannot be empty")
return "Error: Parameter 'cwd' cannot be empty"
# Check if working directory is allowed
if not self.permission_manager.is_path_allowed(cwd):
await tool_ctx.error(f"Working directory not allowed: {cwd}")
return f"Error: Working directory not allowed: {cwd}"
# Check if working directory exists
if not os.path.isdir(cwd):
await tool_ctx.error(f"Working directory does not exist: {cwd}")
return f"Error: Working directory does not exist: {cwd}"
# Execute the script
result: CommandResult = await self.execute_script(
script=script,
interpreter=interpreter,
cwd=cwd, # cwd is now a required parameter
timeout=30.0,
use_login_shell=use_login_shell,
)
# Report result
if result.is_success:
await tool_ctx.info("Script executed successfully")
else:
await tool_ctx.error(
f"Script execution failed with exit code {result.return_code}"
)
# Format the result
if result.is_success:
# For successful scripts, just return stdout unless stderr has content
if result.stderr:
return f"Script executed successfully.\n\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
return result.stdout
else:
# For failed scripts, include all available information
return result.format_output()
# Script tool for executing scripts in various languages
@mcp_server.tool()
async def script_tool(
language: str,
script: str,
cwd: str,
ctx: MCPContext,
args: list[str] | None = None,
use_login_shell: bool = True,
) -> str:
"""Execute a script in the specified language.
Args:
language: The programming language (python, javascript, etc.)
script: The script code to execute
cwd: Working directory for script execution
args: Optional command-line arguments
use_login_shell: Whether to use login shell (loads ~/.zshrc, ~/.bashrc, etc.)
Returns:
Script execution results
"""
tool_ctx = create_tool_context(ctx)
tool_ctx.set_tool_info("script_tool")
# Validate required parameters
if not language:
await tool_ctx.error("Parameter 'language' is required but was None")
return "Error: Parameter 'language' is required but was None"
if language.strip() == "":
await tool_ctx.error("Parameter 'language' cannot be empty")
return "Error: Parameter 'language' cannot be empty"
if not script:
await tool_ctx.error("Parameter 'script' is required but was None")
return "Error: Parameter 'script' is required but was None"
if script.strip() == "":
await tool_ctx.error("Parameter 'script' cannot be empty")
return "Error: Parameter 'script' cannot be empty"
# args can be None as it's optional
# Check for empty list but still allow None
if args is not None and len(args) == 0:
await tool_ctx.warning("Parameter 'args' is an empty list")
# We don't return error for this as empty args is acceptable
# Validate required cwd parameter
if not cwd:
await tool_ctx.error("Parameter 'cwd' is required but was None")
return "Error: Parameter 'cwd' is required but was None"
if cwd.strip() == "":
await tool_ctx.error("Parameter 'cwd' cannot be empty")
return "Error: Parameter 'cwd' cannot be empty"
await tool_ctx.info(f"Executing {language} script")
# Check if the language is supported
if language not in self.get_available_languages():
await tool_ctx.error(f"Unsupported language: {language}")
return f"Error: Unsupported language: {language}. Supported languages: {', '.join(self.get_available_languages())}"
# Check if working directory is allowed
if not self.permission_manager.is_path_allowed(cwd):
await tool_ctx.error(f"Working directory not allowed: {cwd}")
return f"Error: Working directory not allowed: {cwd}"
# Check if working directory exists
if not os.path.isdir(cwd):
await tool_ctx.error(f"Working directory does not exist: {cwd}")
return f"Error: Working directory does not exist: {cwd}"
# Proceed with execution
await tool_ctx.info(f"Executing {language} script in {cwd}")
# Execute the script
result = await self.execute_script_from_file(
script=script,
language=language,
cwd=cwd, # cwd is now a required parameter
timeout=30.0,
args=args,
use_login_shell=use_login_shell,
)
# Report result
if result.is_success:
await tool_ctx.info(f"{language} script executed successfully")
else:
await tool_ctx.error(
f"{language} script execution failed with exit code {result.return_code}"
)
# Format the result
if result.is_success:
# Format the successful result
output = f"{language} script executed successfully.\n\n"
if result.stdout:
output += f"STDOUT:\n{result.stdout}\n\n"
if result.stderr:
output += f"STDERR:\n{result.stderr}"
return output.strip()
else:
# For failed scripts, include all available information
return result.format_output()