diff --git a/codemcp/main.py b/codemcp/main.py index ec431ea..cf155e2 100644 --- a/codemcp/main.py +++ b/codemcp/main.py @@ -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 @@ -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", diff --git a/e2e/test_run_command.py b/e2e/test_run_command.py new file mode 100644 index 0000000..3d472c2 --- /dev/null +++ b/e2e/test_run_command.py @@ -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", "test@example.com"], 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