diff --git a/codemcp/main.py b/codemcp/main.py index 479e2c9f..0435f281 100644 --- a/codemcp/main.py +++ b/codemcp/main.py @@ -5,6 +5,7 @@ from mcp.server.fastmcp import FastMCP +from .tools.chmod import chmod from .tools.edit_file import edit_file_content from .tools.glob import MAX_RESULTS, glob_files from .tools.grep import grep_files @@ -45,6 +46,7 @@ async def codemcp( reuse_head_chat_id: bool | None = None, # Whether to reuse the chat ID from the HEAD commit thought: str | None = None, # Added for Think tool + mode: str | None = None, # Added for Chmod tool ) -> str: """If and only if the user explicitly asks you to initialize codemcp with path, you should invoke this tool. This will return instructions which you should @@ -93,6 +95,7 @@ async def codemcp( "Glob": {"pattern", "path", "limit", "offset", "chat_id"}, "RM": {"path", "description", "chat_id"}, "Think": {"thought", "chat_id"}, + "Chmod": {"path", "mode", "chat_id"}, } # Check if subtool exists @@ -145,6 +148,8 @@ def normalize_newlines(s): "reuse_head_chat_id": reuse_head_chat_id, # Think tool parameter "thought": thought, + # Chmod tool parameter + "mode": mode, }.items() if value is not None } @@ -296,6 +301,15 @@ def normalize_newlines(s): raise ValueError("thought is required for Think subtool") return await think(thought, chat_id) + + if subtool == "Chmod": + if path is None: + raise ValueError("path is required for Chmod subtool") + if mode is None: + raise ValueError("mode is required for Chmod subtool") + + result = await chmod(path, mode, chat_id) + return result.get("resultForAssistant", "Chmod operation completed") except Exception: logging.error("Exception", exc_info=True) raise diff --git a/codemcp/tools/__init__.py b/codemcp/tools/__init__.py index 12fe1474..202e8e2c 100644 --- a/codemcp/tools/__init__.py +++ b/codemcp/tools/__init__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Implement code_command.py utilities here +from .chmod import chmod from .git_blame import git_blame from .git_diff import git_diff from .git_log import git_log @@ -8,6 +9,7 @@ from .rm import rm_file __all__ = [ + "chmod", "git_blame", "git_diff", "git_log", diff --git a/codemcp/tools/chmod.py b/codemcp/tools/chmod.py new file mode 100644 index 00000000..a0e5d036 --- /dev/null +++ b/codemcp/tools/chmod.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +import logging +import os +import stat +import time +from typing import Any, Literal + +from ..common import normalize_file_path +from ..git import commit_changes +from ..shell import run_command + +__all__ = [ + "chmod", + "render_result_for_assistant", + "TOOL_NAME_FOR_PROMPT", + "DESCRIPTION", +] + +TOOL_NAME_FOR_PROMPT = "Chmod" +DESCRIPTION = """ +Changes file permissions using chmod. Unlike standard chmod, this tool only supports +a+x (add executable permission) and a-x (remove executable permission), because these +are the only bits that git knows how to track. + +Example: + chmod a+x path/to/file # Makes a file executable by all users + chmod a-x path/to/file # Makes a file non-executable for all users +""" + + +async def chmod( + path: str, + mode: Literal["a+x", "a-x"], + chat_id: str | None = None, + signal=None, +) -> dict[str, Any]: + """Change file permissions using chmod. + + Args: + path: The absolute path to the file to modify + mode: The chmod mode to apply, only "a+x" and "a-x" are supported + chat_id: The unique ID of the current chat session + signal: Optional abort signal to terminate the subprocess + + Returns: + A dictionary with execution stats and chmod output + """ + start_time = time.time() + + if not path: + raise ValueError("File path must be provided") + + # Normalize the file path + absolute_path = normalize_file_path(path) + + # Check if file exists + if not os.path.exists(absolute_path): + raise FileNotFoundError(f"The file does not exist: {path}") + + # Verify that the mode is supported + if mode not in ["a+x", "a-x"]: + raise ValueError( + f"Unsupported chmod mode: {mode}. Only 'a+x' and 'a-x' are supported." + ) + + # Get the directory containing the file for git operations + directory = os.path.dirname(absolute_path) + + try: + # Check current file permissions + current_mode = os.stat(absolute_path).st_mode + is_executable = bool(current_mode & stat.S_IXUSR) + + if mode == "a+x" and is_executable: + message = f"File '{path}' is already executable" + return { + "output": message, + "durationMs": int((time.time() - start_time) * 1000), + "resultForAssistant": message, + } + elif mode == "a-x" and not is_executable: + message = f"File '{path}' is already non-executable" + return { + "output": message, + "durationMs": int((time.time() - start_time) * 1000), + "resultForAssistant": message, + } + + # Execute chmod command + cmd = ["chmod", mode, absolute_path] + await run_command( + cmd=cmd, + cwd=directory, + capture_output=True, + text=True, + check=True, + ) + + # Prepare success message + if mode == "a+x": + description = f"Make '{os.path.basename(absolute_path)}' executable" + action_msg = f"Made file '{path}' executable" + else: + description = ( + f"Remove executable permission from '{os.path.basename(absolute_path)}'" + ) + action_msg = f"Removed executable permission from file '{path}'" + + # Commit the changes + success, commit_message = await commit_changes( + directory, + description, + chat_id, + ) + + if not success: + logging.warning(f"Failed to commit chmod changes: {commit_message}") + return { + "output": f"{action_msg}, but failed to commit changes: {commit_message}", + "durationMs": int((time.time() - start_time) * 1000), + "resultForAssistant": f"{action_msg}, but failed to commit changes: {commit_message}", + } + + # Calculate execution time + execution_time = int( + (time.time() - start_time) * 1000 + ) # Convert to milliseconds + + # Prepare output + output = { + "output": f"{action_msg} and committed changes", + "durationMs": execution_time, + } + + # Add formatted result for assistant + output["resultForAssistant"] = render_result_for_assistant(output) + + return output + except Exception as e: + logging.exception(f"Error executing chmod: {e!s}") + error_message = f"Error executing chmod: {e!s}" + return { + "output": error_message, + "durationMs": int((time.time() - start_time) * 1000), + "resultForAssistant": error_message, + } + + +def render_result_for_assistant(output: dict[str, Any]) -> str: + """Render the results in a format suitable for the assistant. + + Args: + output: The chmod output dictionary + + Returns: + A formatted string representation of the results + """ + return output.get("output", "") diff --git a/codemcp/tools/init_project.py b/codemcp/tools/init_project.py index 3f943648..53b2bfab 100644 --- a/codemcp/tools/init_project.py +++ b/codemcp/tools/init_project.py @@ -453,10 +453,25 @@ async def init_project( description: Short description of why the file is being removed chat_id: The unique ID to identify the chat session +## Chmod chat_id path mode + +Changes file permissions using chmod. Unlike standard chmod, this tool only supports +a+x (add executable permission) and a-x (remove executable permission), because these +are the only bits that git knows how to track. + +Args: + path: The absolute path to the file to modify + mode: The chmod mode to apply, only "a+x" and "a-x" are supported + chat_id: The unique ID to identify the chat session + +Example: + chmod a+x path/to/file # Makes a file executable by all users + chmod a-x path/to/file # Makes a file non-executable for all users + ## Summary Args: - subtool: The subtool to execute (ReadFile, WriteFile, EditFile, LS, InitProject, UserPrompt, RunCommand, RM, Think) + subtool: The subtool to execute (ReadFile, WriteFile, EditFile, LS, InitProject, UserPrompt, RunCommand, RM, Think, Chmod) path: The path to the file or directory to operate on content: Content for WriteFile subtool old_string: String to replace for EditFile subtool @@ -467,6 +482,7 @@ async def init_project( arguments: A string containing space-separated arguments for RunCommand subtool user_prompt: The user's verbatim text (for UserPrompt subtool) thought: The thought content (for Think subtool) + mode: The chmod mode to apply (a+x or a-x) for Chmod subtool chat_id: A unique ID to identify the chat session (required for all tools EXCEPT InitProject) # Chat ID diff --git a/e2e/test_chmod.py b/e2e/test_chmod.py new file mode 100644 index 00000000..b1ac61c2 --- /dev/null +++ b/e2e/test_chmod.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 + +"""Tests for the Chmod subtool.""" + +import os +import stat +import unittest + +from codemcp.testing import MCPEndToEndTestCase + + +class ChmodTest(MCPEndToEndTestCase): + """Test the Chmod subtool.""" + + async def test_chmod_basic_functionality(self): + """Test basic functionality of the chmod tool.""" + # Create a test script file + test_file_path = os.path.join(self.temp_dir.name, "test_script.py") + with open(test_file_path, "w") as f: + f.write("#!/usr/bin/env python3\nprint('Hello, world!')\n") + + # Initial state - file should not be executable + mode = os.stat(test_file_path).st_mode + is_executable = bool(mode & stat.S_IXUSR) + self.assertFalse(is_executable, "File should not be executable initially") + + async with self.create_client_session() as session: + # Get a valid chat_id + chat_id = await self.get_chat_id(session) + + # Make the file executable + result_text = await self.call_tool_assert_success( + session, + "codemcp", + { + "subtool": "Chmod", + "path": test_file_path, + "mode": "a+x", + "chat_id": chat_id, + }, + ) + + # Verify success message + self.assertIn("Made file", result_text) + + # Verify file is now executable + mode = os.stat(test_file_path).st_mode + is_executable = bool(mode & stat.S_IXUSR) + self.assertTrue(is_executable, "File should be executable after chmod a+x") + + # Try making it executable again (should be a no-op) + result_text = await self.call_tool_assert_success( + session, + "codemcp", + { + "subtool": "Chmod", + "path": test_file_path, + "mode": "a+x", + "chat_id": chat_id, + }, + ) + + # Verify no-op message + self.assertIn("already executable", result_text) + + # Remove executable permission + result_text = await self.call_tool_assert_success( + session, + "codemcp", + { + "subtool": "Chmod", + "path": test_file_path, + "mode": "a-x", + "chat_id": chat_id, + }, + ) + + # Verify success message + self.assertIn("Removed executable permission", result_text) + + # Verify file is no longer executable + mode = os.stat(test_file_path).st_mode + is_executable = bool(mode & stat.S_IXUSR) + self.assertFalse(is_executable, "File should not be executable after chmod a-x") + + # Try removing executable permission again (should be a no-op) + result_text = await self.call_tool_assert_success( + session, + "codemcp", + { + "subtool": "Chmod", + "path": test_file_path, + "mode": "a-x", + "chat_id": chat_id, + }, + ) + + # Verify no-op message + self.assertIn("already non-executable", result_text) + + async def test_chmod_error_handling(self): + """Test error handling in the chmod tool.""" + async with self.create_client_session() as session: + # Get a valid chat_id + chat_id = await self.get_chat_id(session) + + # Test with non-existent file + non_existent_file = os.path.join(self.temp_dir.name, "nonexistent.py") + error_text = await self.call_tool_assert_error( + session, + "codemcp", + { + "subtool": "Chmod", + "path": non_existent_file, + "mode": "a+x", + "chat_id": chat_id, + }, + ) + self.assertIn("not exist", error_text.lower()) + + # Test with invalid mode + test_file = os.path.join(self.temp_dir.name, "test_file.py") + with open(test_file, "w") as f: + f.write("# Test file") + + error_text = await self.call_tool_assert_error( + session, + "codemcp", + { + "subtool": "Chmod", + "path": test_file, + "mode": "invalid", + "chat_id": chat_id, + }, + ) + self.assertIn("unsupported chmod mode", error_text.lower()) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file