Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ChmodExec tool for making files executable #197

Open
wants to merge 1 commit into
base: gh/ezyang/149/base
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions codemcp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from mcp.server.fastmcp import FastMCP

from .tools.chmod_exec import chmod_exec
from .tools.edit_file import edit_file_content
from .tools.glob import MAX_RESULTS, glob_files
from .tools.grep import grep_files
Expand Down Expand Up @@ -93,6 +94,7 @@ async def codemcp(
"Glob": {"pattern", "path", "limit", "offset", "chat_id"},
"RM": {"path", "description", "chat_id"},
"Think": {"thought", "chat_id"},
"ChmodExec": {"path", "description", "chat_id"},
}

# Check if subtool exists
Expand Down Expand Up @@ -296,6 +298,14 @@ def normalize_newlines(s):
raise ValueError("thought is required for Think subtool")

return await think(thought, chat_id)

if subtool == "ChmodExec":
if path is None:
raise ValueError("path is required for ChmodExec subtool")
if description is None:
raise ValueError("description is required for ChmodExec subtool")

return await chmod_exec(path, description, chat_id)
except Exception:
logging.error("Exception", exc_info=True)
raise
Expand Down
2 changes: 2 additions & 0 deletions codemcp/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
#!/usr/bin/env python3
# Implement code_command.py utilities here

from .chmod_exec import chmod_exec
from .git_blame import git_blame
from .git_diff import git_diff
from .git_log import git_log
from .git_show import git_show
from .rm import rm_file

__all__ = [
"chmod_exec",
"git_blame",
"git_diff",
"git_log",
Expand Down
73 changes: 73 additions & 0 deletions codemcp/tools/chmod_exec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env python3

import logging
import os

from ..common import normalize_file_path
from ..git import commit_changes, is_git_repository
from ..shell import run_command

__all__ = [
"chmod_exec",
]


async def chmod_exec(
path: str,
description: str,
chat_id: str = None,
) -> str:
"""Make a file executable by running chmod a+x on it.

Args:
path: The path to the file to make executable
description: A short description of why the file is being made executable
chat_id: The unique ID of the current chat session

Returns:
A string containing the result of the operation
"""
try:
# Normalize the file path
full_file_path = normalize_file_path(path)

# Verify the file exists
if not os.path.exists(full_file_path):
raise FileNotFoundError(f"File does not exist: {path}")

# Verify it's a file, not a directory
if not os.path.isfile(full_file_path):
raise IsADirectoryError(f"Path is a directory, not a file: {path}")

# Get the directory containing the file for git operations
file_dir = os.path.dirname(full_file_path)

# Run chmod a+x on the file
logging.info(f"Making file executable: {full_file_path}")
await run_command(
["chmod", "a+x", full_file_path],
check=True,
capture_output=True,
text=True,
)

# If this is a git repository, commit the change
is_git_repo = await is_git_repository(file_dir)
if is_git_repo:
commit_message = (
f"Make {os.path.basename(full_file_path)} executable: {description}"
)
success, commit_result = await commit_changes(
file_dir, commit_message, chat_id, commit_all=True
)
if success:
return f"Successfully made {os.path.basename(full_file_path)} executable and committed the change."
else:
return f"Successfully made {os.path.basename(full_file_path)} executable but failed to commit the change: {commit_result}"

return f"Successfully made {os.path.basename(full_file_path)} executable."

except Exception as e:
error_msg = f"Error making file executable: {e}"
logging.error(error_msg)
raise
18 changes: 16 additions & 2 deletions codemcp/tools/init_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,17 +453,31 @@ async def init_project(
description: Short description of why the file is being removed
chat_id: The unique ID to identify the chat session

## ChmodExec chat_id path description

Makes a file executable by running chmod a+x on it.
Provide a short description of why the file is being made executable.

Before using this tool:
1. Ensure the file exists
2. Provide a meaningful description of why the file needs to be executable

Args:
path: The path to the file to make executable (can be relative to the project root or absolute)
description: Short description of why the file is being made executable
chat_id: The unique ID to identify the chat session

## 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, ChmodExec)
path: The path to the file or directory to operate on
content: Content for WriteFile subtool
old_string: String to replace for EditFile subtool
new_string: Replacement string for EditFile subtool
offset: Line offset for ReadFile subtool
limit: Line limit for ReadFile subtool
description: Short description of the change (for WriteFile/EditFile/RM)
description: Short description of the change (for WriteFile/EditFile/RM/ChmodExec)
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)
Expand Down
137 changes: 137 additions & 0 deletions e2e/test_chmod_exec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env python3

"""End-to-end tests for the ChmodExec tool."""

import os
import stat
import unittest

from codemcp.testing import MCPEndToEndTestCase


class ChmodExecTest(MCPEndToEndTestCase):
"""Test the ChmodExec subtool functionality."""

async def test_chmod_exec_file(self):
"""Test making a file executable using the ChmodExec subtool."""
# Create a test file
test_file_path = os.path.join(self.temp_dir.name, "file_to_chmod.sh")
with open(test_file_path, "w") as f:
f.write("#!/bin/sh\necho 'Hello World'")

# Add the file using git
await self.git_run(["add", "file_to_chmod.sh"])
await self.git_run(["commit", "-m", "Add file that will be made executable"])

# Check initial permissions (should not be executable)
initial_mode = os.stat(test_file_path).st_mode
initial_executable = bool(initial_mode & stat.S_IXUSR)

# Initial count of commits
initial_log = await self.git_run(
["log", "--oneline"], capture_output=True, text=True
)
initial_commit_count = len(initial_log.strip().split("\n"))

async with self.create_client_session() as session:
# Get a valid chat_id
chat_id = await self.get_chat_id(session)

# For debugging, print some path information
print(f"DEBUG - Test file path: {test_file_path}")
# Check if file is executable before
print(f"DEBUG - File is executable before: {initial_executable}")

# Call the ChmodExec tool with the chat_id - use absolute path
result = await self.call_tool_assert_success(
session,
"codemcp",
{
"subtool": "ChmodExec",
"path": test_file_path, # Use absolute path
"description": "Make shell script executable",
"chat_id": chat_id,
},
)

# Print the result for debugging
print(f"DEBUG - ChmodExec result: {result}")

# Check that the file is now executable
final_mode = os.stat(test_file_path).st_mode
final_executable = bool(final_mode & stat.S_IXUSR)
print(f"DEBUG - File is executable after: {final_executable}")

self.assertTrue(final_executable, "File should have been made executable")

# Verify the output message indicates success
self.assertIn("Successfully made", result)
self.assertIn("executable", result)

# Verify a commit was created for the change
final_log = await self.git_run(
["log", "--oneline"], capture_output=True, text=True
)
final_commit_count = len(final_log.strip().split("\n"))
self.assertEqual(
final_commit_count,
initial_commit_count + 1,
"Should have one more commit",
)

# Verify the commit message contains the description
latest_commit_msg = await self.git_run(
["log", "-1", "--pretty=%B"], capture_output=True, text=True
)
self.assertIn("Make file_to_chmod.sh executable", latest_commit_msg)
self.assertIn("Make shell script executable", latest_commit_msg)

async def test_chmod_exec_file_does_not_exist(self):
"""Test attempting to make a non-existent file executable."""
async with self.create_client_session() as session:
# Get a valid chat_id
chat_id = await self.get_chat_id(session)

# Attempt to chmod a file that doesn't exist - should fail
result = await self.call_tool_assert_error(
session,
"codemcp",
{
"subtool": "ChmodExec",
"path": "non_existent_file.sh",
"description": "Make non-existent file executable",
"chat_id": chat_id,
},
)

# Verify the operation failed with proper error message
self.assertIn("File does not exist", result)

async def test_chmod_exec_directory(self):
"""Test attempting to make a directory executable."""
# Create a test directory
test_dir_path = os.path.join(self.temp_dir.name, "test_directory")
os.makedirs(test_dir_path, exist_ok=True)

async with self.create_client_session() as session:
# Get a valid chat_id
chat_id = await self.get_chat_id(session)

# Attempt to chmod a directory - should fail
result = await self.call_tool_assert_error(
session,
"codemcp",
{
"subtool": "ChmodExec",
"path": test_dir_path,
"description": "Make directory executable",
"chat_id": chat_id,
},
)

# Verify the operation failed with proper error message
self.assertIn("is a directory", result.lower())


if __name__ == "__main__":
unittest.main()