Skip to content

Commit 4976615

Browse files
committed
feat: add run command to CLI
Add a `codemcp run COMMAND` to the cli which runs the command as defined in codemcp.toml the same way the RunCommand tool does it. ```git-revs ef0b2d8 (Base revision) b00e0fa Add run command to CLI 51594c3 Add import for get_command_from_config 9ad7dd6 Add end-to-end test for run command CLI 4253b3d Auto-commit format changes 875d7e5 Auto-commit lint changes 402a071 Update the test assertions to match the actual error message 29ae4e6 Update CLI run command to stream output to terminal in real-time 62d2cd0 Update test to use --no-stream flag for consistent testing 980e370 Update test_run_command_with_args to use --no-stream flag 2efae9a Update test_run_command_not_found to use --no-stream flag dd4060e Update test_run_command_empty_definition to use --no-stream flag 8601a6a Add test for streaming mode d2e92b9 Fix test for streaming mode to work with proper mocking 88fd447 Auto-commit format changes c6c1813 Auto-commit lint changes 125536b Fix test to prevent hanging in pytest by ensuring proper cleanup of mocks ff9a32b Add proper process cleanup on keyboard interrupt 6972726 Fix coroutine warning by properly mocking asyncio.run 285cf01 Complete rewrite of test file to properly mock async functions and prevent warnings a588d61 Auto-commit format changes HEAD Auto-commit lint changes ``` codemcp-id: 277-feat-add-run-command-to-cli ghstack-source-id: 391e2b3 Pull-Request-resolved: #270
1 parent ab13daa commit 4976615

File tree

2 files changed

+305
-0
lines changed

2 files changed

+305
-0
lines changed

codemcp/main.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from starlette.applications import Starlette
1515
from starlette.routing import Mount
1616

17+
from .code_command import get_command_from_config
1718
from .common import normalize_file_path
1819
from .git_query import get_current_commit_hash
1920
from .tools.chmod import chmod
@@ -861,6 +862,149 @@ def run() -> None:
861862
mcp.run()
862863

863864

865+
@cli.command()
866+
@click.argument("command", type=str, required=True)
867+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
868+
@click.option(
869+
"--path",
870+
type=click.Path(exists=True),
871+
default=".",
872+
help="Path to the project directory (default: current directory)",
873+
)
874+
@click.option(
875+
"--no-stream",
876+
is_flag=True,
877+
help="Don't stream output to the terminal in real-time",
878+
)
879+
def run(command: str, args: List[str], path: str, no_stream: bool) -> None:
880+
"""Run a command defined in codemcp.toml.
881+
882+
COMMAND: The name of the command to run as defined in codemcp.toml
883+
ARGS: Optional arguments to pass to the command
884+
"""
885+
import asyncio
886+
import subprocess
887+
from uuid import uuid4
888+
889+
# Configure logging
890+
configure_logging()
891+
892+
# Convert args tuple to a space-separated string
893+
args_str = " ".join(args) if args else None
894+
895+
# Generate a temporary chat ID for this command
896+
chat_id = str(uuid4())
897+
898+
# Convert to absolute path if needed
899+
project_dir = normalize_file_path(path)
900+
901+
try:
902+
# Check if command exists in config
903+
command_list = get_command_from_config(project_dir, command)
904+
if not command_list:
905+
click.echo(
906+
f"Error: Command '{command}' not found in codemcp.toml", err=True
907+
)
908+
return
909+
910+
if no_stream:
911+
# Use the standard non-streaming implementation
912+
result = asyncio.run(run_command(project_dir, command, args_str, chat_id))
913+
click.echo(result)
914+
else:
915+
# Check if directory is in a git repository and commit any pending changes
916+
from .git import commit_changes, is_git_repository
917+
918+
is_git_repo = asyncio.run(is_git_repository(project_dir))
919+
if is_git_repo:
920+
logging.info(f"Committing any pending changes before {command}")
921+
commit_result = asyncio.run(
922+
commit_changes(
923+
project_dir,
924+
f"Snapshot before auto-{command}",
925+
chat_id,
926+
commit_all=True,
927+
)
928+
)
929+
if not commit_result[0]:
930+
logging.warning(
931+
f"Failed to commit pending changes: {commit_result[1]}"
932+
)
933+
934+
# Extend the command with arguments if provided
935+
full_command = command_list.copy()
936+
if args_str:
937+
import shlex
938+
939+
parsed_args = shlex.split(args_str)
940+
full_command.extend(parsed_args)
941+
942+
# Stream output to the terminal in real-time
943+
click.echo(f"Running command: {' '.join(str(c) for c in full_command)}")
944+
945+
# Run the command with live output streaming
946+
try:
947+
process = subprocess.Popen(
948+
full_command,
949+
cwd=project_dir,
950+
stdout=None, # Use parent's stdout/stderr (the terminal)
951+
stderr=None,
952+
text=True,
953+
bufsize=0, # Unbuffered
954+
)
955+
956+
try:
957+
# Wait for the process to complete
958+
exit_code = process.wait()
959+
except KeyboardInterrupt:
960+
# Handle Ctrl+C gracefully
961+
process.terminate()
962+
try:
963+
process.wait(timeout=1)
964+
except subprocess.TimeoutExpired:
965+
process.kill()
966+
click.echo("\nProcess terminated by user.")
967+
return
968+
969+
# Check if command succeeded
970+
if exit_code == 0:
971+
# If it's a git repo, commit any changes made by the command
972+
if is_git_repo:
973+
from .code_command import check_for_changes
974+
975+
has_changes = asyncio.run(check_for_changes(project_dir))
976+
if has_changes:
977+
logging.info(
978+
f"Changes detected after {command}, committing"
979+
)
980+
success, commit_result_message = asyncio.run(
981+
commit_changes(
982+
project_dir,
983+
f"Auto-commit {command} changes",
984+
chat_id,
985+
commit_all=True,
986+
)
987+
)
988+
989+
if success:
990+
click.echo(
991+
f"\nCode {command} successful and changes committed."
992+
)
993+
else:
994+
click.echo(
995+
f"\nCode {command} successful but failed to commit changes."
996+
)
997+
click.echo(f"Commit error: {commit_result_message}")
998+
else:
999+
click.echo(f"\nCode {command} successful.")
1000+
else:
1001+
click.echo(f"\nCommand failed with exit code {exit_code}.")
1002+
except Exception as cmd_error:
1003+
click.echo(f"Error during command execution: {cmd_error}", err=True)
1004+
except Exception as e:
1005+
click.echo(f"Error running command: {e}", err=True)
1006+
1007+
8641008
@cli.command()
8651009
@click.option(
8661010
"--host",

e2e/test_run_command.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
#!/usr/bin/env python3
2+
3+
import subprocess
4+
import tempfile
5+
from pathlib import Path
6+
from unittest.mock import MagicMock, patch
7+
8+
import pytest
9+
from click.testing import CliRunner
10+
11+
import codemcp.git
12+
from codemcp.main import cli
13+
14+
15+
# Create non-async mock functions to replace async ones
16+
def mock_is_git_repository(*args, **kwargs):
17+
return False
18+
19+
20+
def mock_check_for_changes(*args, **kwargs):
21+
return False
22+
23+
24+
def mock_commit_changes(*args, **kwargs):
25+
return (True, "Mock commit message")
26+
27+
28+
# Patch the modules directly
29+
codemcp.git.is_git_repository = mock_is_git_repository
30+
31+
32+
@pytest.fixture
33+
def test_project():
34+
"""Create a temporary directory with a codemcp.toml file for testing."""
35+
with tempfile.TemporaryDirectory() as tmp_dir:
36+
# Create a codemcp.toml file with test commands
37+
config_path = Path(tmp_dir) / "codemcp.toml"
38+
with open(config_path, "w") as f:
39+
f.write("""[commands]
40+
echo = ["echo", "Hello from codemcp run!"]
41+
echo_args = ["echo"]
42+
invalid = []
43+
""")
44+
45+
# Initialize a git repository
46+
subprocess.run(["git", "init"], cwd=tmp_dir, check=True, capture_output=True)
47+
subprocess.run(
48+
["git", "config", "user.name", "Test User"], cwd=tmp_dir, check=True
49+
)
50+
subprocess.run(
51+
["git", "config", "user.email", "[email protected]"], cwd=tmp_dir, check=True
52+
)
53+
subprocess.run(["git", "add", "codemcp.toml"], cwd=tmp_dir, check=True)
54+
subprocess.run(
55+
["git", "commit", "-m", "Initial commit"], cwd=tmp_dir, check=True
56+
)
57+
58+
yield tmp_dir
59+
60+
61+
def test_run_command_success(test_project):
62+
"""Test running a command successfully."""
63+
runner = CliRunner()
64+
result = runner.invoke(cli, ["run", "echo", "--path", test_project, "--no-stream"])
65+
66+
assert result.exit_code == 0
67+
assert "Hello from codemcp run!" in result.output
68+
assert "Code echo successful" in result.output
69+
70+
71+
def test_run_command_with_args(test_project):
72+
"""Test running a command with arguments."""
73+
runner = CliRunner()
74+
result = runner.invoke(
75+
cli,
76+
[
77+
"run",
78+
"echo_args",
79+
"Test",
80+
"argument",
81+
"string",
82+
"--path",
83+
test_project,
84+
"--no-stream",
85+
],
86+
)
87+
88+
assert result.exit_code == 0
89+
assert "Test argument string" in result.output
90+
assert "Code echo_args successful" in result.output
91+
92+
93+
def test_run_command_not_found(test_project):
94+
"""Test running a command that doesn't exist in config."""
95+
runner = CliRunner()
96+
result = runner.invoke(
97+
cli, ["run", "nonexistent", "--path", test_project, "--no-stream"]
98+
)
99+
100+
assert "Error: Command 'nonexistent' not found in codemcp.toml" in result.output
101+
102+
103+
def test_run_command_empty_definition(test_project):
104+
"""Test running a command with an empty definition."""
105+
runner = CliRunner()
106+
result = runner.invoke(
107+
cli, ["run", "invalid", "--path", test_project, "--no-stream"]
108+
)
109+
110+
assert "Error: Command 'invalid' not found in codemcp.toml" in result.output
111+
112+
113+
@patch("codemcp.git.is_git_repository", mock_is_git_repository)
114+
@patch("codemcp.code_command.check_for_changes", mock_check_for_changes)
115+
@patch("codemcp.git.commit_changes", mock_commit_changes)
116+
@patch(
117+
"asyncio.run", lambda x: False
118+
) # Mock asyncio.run to return False for all coroutines
119+
def test_run_command_stream_mode(test_project):
120+
"""Test running a command with streaming mode."""
121+
import subprocess
122+
123+
# Create a mock for subprocess.Popen
124+
mock_process = MagicMock()
125+
mock_process.returncode = 0
126+
mock_process.wait.return_value = 0
127+
128+
# Keep track of Popen calls
129+
popen_calls = []
130+
131+
# Create a safe replacement for Popen that won't leave hanging processes
132+
original_popen = subprocess.Popen
133+
134+
def mock_popen(cmd, **kwargs):
135+
if (
136+
isinstance(cmd, list)
137+
and cmd[0] == "echo"
138+
and "Hello from codemcp run!" in cmd
139+
):
140+
popen_calls.append((cmd, kwargs))
141+
return mock_process
142+
# For any other command, create a safe echo process with proper cleanup
143+
return original_popen(
144+
["echo", "Test"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
145+
)
146+
147+
with patch("subprocess.Popen", mock_popen):
148+
# Run the command with isolated stdin/stdout to prevent interference
149+
runner = CliRunner(mix_stderr=False)
150+
runner.invoke(cli, ["run", "echo", "--path", test_project])
151+
152+
# Check that our command was executed with the right parameters
153+
assert any(cmd == ["echo", "Hello from codemcp run!"] for cmd, _ in popen_calls)
154+
155+
# Find the call for our echo command
156+
for cmd, kwargs in popen_calls:
157+
if cmd == ["echo", "Hello from codemcp run!"]:
158+
# Verify streaming parameters
159+
assert kwargs.get("stdout") is None
160+
assert kwargs.get("stderr") is None
161+
assert kwargs.get("bufsize") == 0

0 commit comments

Comments
 (0)