Skip to content
Closed
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
14 changes: 14 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 import chmod
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 @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
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 import chmod
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",
"git_blame",
"git_diff",
"git_log",
Expand Down
159 changes: 159 additions & 0 deletions codemcp/tools/chmod.py
Original file line number Diff line number Diff line change
@@ -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", "")
18 changes: 17 additions & 1 deletion codemcp/tools/init_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading