Skip to content
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
144 changes: 144 additions & 0 deletions codemcp/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from starlette.applications import Starlette
from starlette.routing import Mount

from .code_command import get_command_from_config
from .common import normalize_file_path
from .git_query import get_current_commit_hash
from .tools.chmod import chmod
Expand Down Expand Up @@ -861,6 +862,149 @@ def run() -> None:
mcp.run()


@cli.command()
@click.argument("command", type=str, required=True)
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
@click.option(
"--path",
type=click.Path(exists=True),
default=".",
help="Path to the project directory (default: current directory)",
)
@click.option(
"--no-stream",
is_flag=True,
help="Don't stream output to the terminal in real-time",
)
def run(command: str, args: List[str], path: str, no_stream: bool) -> None:
"""Run a command defined in codemcp.toml.

COMMAND: The name of the command to run as defined in codemcp.toml
ARGS: Optional arguments to pass to the command
"""
import asyncio
import subprocess
from uuid import uuid4

# Configure logging
configure_logging()

# Convert args tuple to a space-separated string
args_str = " ".join(args) if args else None

# Generate a temporary chat ID for this command
chat_id = str(uuid4())

# Convert to absolute path if needed
project_dir = normalize_file_path(path)

try:
# Check if command exists in config
command_list = get_command_from_config(project_dir, command)
if not command_list:
click.echo(
f"Error: Command '{command}' not found in codemcp.toml", err=True
)
return

if no_stream:
# Use the standard non-streaming implementation
result = asyncio.run(run_command(project_dir, command, args_str, chat_id))
click.echo(result)
else:
# Check if directory is in a git repository and commit any pending changes
from .git import commit_changes, is_git_repository

is_git_repo = asyncio.run(is_git_repository(project_dir))
if is_git_repo:
logging.info(f"Committing any pending changes before {command}")
commit_result = asyncio.run(
commit_changes(
project_dir,
f"Snapshot before auto-{command}",
chat_id,
commit_all=True,
)
)
if not commit_result[0]:
logging.warning(
f"Failed to commit pending changes: {commit_result[1]}"
)

# Extend the command with arguments if provided
full_command = command_list.copy()
if args_str:
import shlex

parsed_args = shlex.split(args_str)
full_command.extend(parsed_args)

# Stream output to the terminal in real-time
click.echo(f"Running command: {' '.join(str(c) for c in full_command)}")

# Run the command with live output streaming
try:
process = subprocess.Popen(
full_command,
cwd=project_dir,
stdout=None, # Use parent's stdout/stderr (the terminal)
stderr=None,
text=True,
bufsize=0, # Unbuffered
)

try:
# Wait for the process to complete
exit_code = process.wait()
except KeyboardInterrupt:
# Handle Ctrl+C gracefully
process.terminate()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
process.kill()
click.echo("\nProcess terminated by user.")
return

# Check if command succeeded
if exit_code == 0:
# If it's a git repo, commit any changes made by the command
if is_git_repo:
from .code_command import check_for_changes

has_changes = asyncio.run(check_for_changes(project_dir))
if has_changes:
logging.info(
f"Changes detected after {command}, committing"
)
success, commit_result_message = asyncio.run(
commit_changes(
project_dir,
f"Auto-commit {command} changes",
chat_id,
commit_all=True,
)
)

if success:
click.echo(
f"\nCode {command} successful and changes committed."
)
else:
click.echo(
f"\nCode {command} successful but failed to commit changes."
)
click.echo(f"Commit error: {commit_result_message}")
else:
click.echo(f"\nCode {command} successful.")
else:
click.echo(f"\nCommand failed with exit code {exit_code}.")
except Exception as cmd_error:
click.echo(f"Error during command execution: {cmd_error}", err=True)
except Exception as e:
click.echo(f"Error running command: {e}", err=True)


@cli.command()
@click.option(
"--host",
Expand Down
161 changes: 161 additions & 0 deletions e2e/test_run_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env python3

import subprocess
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
from click.testing import CliRunner

import codemcp.git
from codemcp.main import cli


# Create non-async mock functions to replace async ones
def mock_is_git_repository(*args, **kwargs):
return False


def mock_check_for_changes(*args, **kwargs):
return False


def mock_commit_changes(*args, **kwargs):
return (True, "Mock commit message")


# Patch the modules directly
codemcp.git.is_git_repository = mock_is_git_repository


@pytest.fixture
def test_project():
"""Create a temporary directory with a codemcp.toml file for testing."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a codemcp.toml file with test commands
config_path = Path(tmp_dir) / "codemcp.toml"
with open(config_path, "w") as f:
f.write("""[commands]
echo = ["echo", "Hello from codemcp run!"]
echo_args = ["echo"]
invalid = []
""")

# Initialize a git repository
subprocess.run(["git", "init"], cwd=tmp_dir, check=True, capture_output=True)
subprocess.run(
["git", "config", "user.name", "Test User"], cwd=tmp_dir, check=True
)
subprocess.run(
["git", "config", "user.email", "[email protected]"], cwd=tmp_dir, check=True
)
subprocess.run(["git", "add", "codemcp.toml"], cwd=tmp_dir, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"], cwd=tmp_dir, check=True
)

yield tmp_dir


def test_run_command_success(test_project):
"""Test running a command successfully."""
runner = CliRunner()
result = runner.invoke(cli, ["run", "echo", "--path", test_project, "--no-stream"])

assert result.exit_code == 0
assert "Hello from codemcp run!" in result.output
assert "Code echo successful" in result.output


def test_run_command_with_args(test_project):
"""Test running a command with arguments."""
runner = CliRunner()
result = runner.invoke(
cli,
[
"run",
"echo_args",
"Test",
"argument",
"string",
"--path",
test_project,
"--no-stream",
],
)

assert result.exit_code == 0
assert "Test argument string" in result.output
assert "Code echo_args successful" in result.output


def test_run_command_not_found(test_project):
"""Test running a command that doesn't exist in config."""
runner = CliRunner()
result = runner.invoke(
cli, ["run", "nonexistent", "--path", test_project, "--no-stream"]
)

assert "Error: Command 'nonexistent' not found in codemcp.toml" in result.output


def test_run_command_empty_definition(test_project):
"""Test running a command with an empty definition."""
runner = CliRunner()
result = runner.invoke(
cli, ["run", "invalid", "--path", test_project, "--no-stream"]
)

assert "Error: Command 'invalid' not found in codemcp.toml" in result.output


@patch("codemcp.git.is_git_repository", mock_is_git_repository)
@patch("codemcp.code_command.check_for_changes", mock_check_for_changes)
@patch("codemcp.git.commit_changes", mock_commit_changes)
@patch(
"asyncio.run", lambda x: False
) # Mock asyncio.run to return False for all coroutines
def test_run_command_stream_mode(test_project):
"""Test running a command with streaming mode."""
import subprocess

# Create a mock for subprocess.Popen
mock_process = MagicMock()
mock_process.returncode = 0
mock_process.wait.return_value = 0

# Keep track of Popen calls
popen_calls = []

# Create a safe replacement for Popen that won't leave hanging processes
original_popen = subprocess.Popen

def mock_popen(cmd, **kwargs):
if (
isinstance(cmd, list)
and cmd[0] == "echo"
and "Hello from codemcp run!" in cmd
):
popen_calls.append((cmd, kwargs))
return mock_process
# For any other command, create a safe echo process with proper cleanup
return original_popen(
["echo", "Test"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
)

with patch("subprocess.Popen", mock_popen):
# Run the command with isolated stdin/stdout to prevent interference
runner = CliRunner(mix_stderr=False)
runner.invoke(cli, ["run", "echo", "--path", test_project])

# Check that our command was executed with the right parameters
assert any(cmd == ["echo", "Hello from codemcp run!"] for cmd, _ in popen_calls)

# Find the call for our echo command
for cmd, kwargs in popen_calls:
if cmd == ["echo", "Hello from codemcp run!"]:
# Verify streaming parameters
assert kwargs.get("stdout") is None
assert kwargs.get("stderr") is None
assert kwargs.get("bufsize") == 0
Loading