Skip to content

Commit c6c1813

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 HEAD Auto-commit lint changes ``` codemcp-id: 277-feat-add-run-command-to-cli ghstack-source-id: 030b62c Pull-Request-resolved: #270
1 parent ab13daa commit c6c1813

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

codemcp/main.py

Lines changed: 134 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,139 @@ 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+
# Wait for the process to complete
957+
exit_code = process.wait()
958+
959+
# Check if command succeeded
960+
if exit_code == 0:
961+
# If it's a git repo, commit any changes made by the command
962+
if is_git_repo:
963+
from .code_command import check_for_changes
964+
965+
has_changes = asyncio.run(check_for_changes(project_dir))
966+
if has_changes:
967+
logging.info(
968+
f"Changes detected after {command}, committing"
969+
)
970+
success, commit_result_message = asyncio.run(
971+
commit_changes(
972+
project_dir,
973+
f"Auto-commit {command} changes",
974+
chat_id,
975+
commit_all=True,
976+
)
977+
)
978+
979+
if success:
980+
click.echo(
981+
f"\nCode {command} successful and changes committed."
982+
)
983+
else:
984+
click.echo(
985+
f"\nCode {command} successful but failed to commit changes."
986+
)
987+
click.echo(f"Commit error: {commit_result_message}")
988+
else:
989+
click.echo(f"\nCode {command} successful.")
990+
else:
991+
click.echo(f"\nCommand failed with exit code {exit_code}.")
992+
except Exception as cmd_error:
993+
click.echo(f"Error during command execution: {cmd_error}", err=True)
994+
except Exception as e:
995+
click.echo(f"Error running command: {e}", err=True)
996+
997+
864998
@cli.command()
865999
@click.option(
8661000
"--host",

e2e/test_run_command.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
3+
import subprocess
4+
import tempfile
5+
from pathlib import Path
6+
7+
import pytest
8+
from click.testing import CliRunner
9+
10+
from codemcp.main import cli
11+
12+
13+
@pytest.fixture
14+
def test_project():
15+
"""Create a temporary directory with a codemcp.toml file for testing."""
16+
with tempfile.TemporaryDirectory() as tmp_dir:
17+
# Create a codemcp.toml file with test commands
18+
config_path = Path(tmp_dir) / "codemcp.toml"
19+
with open(config_path, "w") as f:
20+
f.write("""[commands]
21+
echo = ["echo", "Hello from codemcp run!"]
22+
echo_args = ["echo"]
23+
invalid = []
24+
""")
25+
26+
# Initialize a git repository
27+
subprocess.run(["git", "init"], cwd=tmp_dir, check=True, capture_output=True)
28+
subprocess.run(
29+
["git", "config", "user.name", "Test User"], cwd=tmp_dir, check=True
30+
)
31+
subprocess.run(
32+
["git", "config", "user.email", "test@example.com"], cwd=tmp_dir, check=True
33+
)
34+
subprocess.run(["git", "add", "codemcp.toml"], cwd=tmp_dir, check=True)
35+
subprocess.run(
36+
["git", "commit", "-m", "Initial commit"], cwd=tmp_dir, check=True
37+
)
38+
39+
yield tmp_dir
40+
41+
42+
def test_run_command_success(test_project):
43+
"""Test running a command successfully."""
44+
runner = CliRunner()
45+
result = runner.invoke(cli, ["run", "echo", "--path", test_project, "--no-stream"])
46+
47+
assert result.exit_code == 0
48+
assert "Hello from codemcp run!" in result.output
49+
assert "Code echo successful" in result.output
50+
51+
52+
def test_run_command_with_args(test_project):
53+
"""Test running a command with arguments."""
54+
runner = CliRunner()
55+
result = runner.invoke(
56+
cli,
57+
[
58+
"run",
59+
"echo_args",
60+
"Test",
61+
"argument",
62+
"string",
63+
"--path",
64+
test_project,
65+
"--no-stream",
66+
],
67+
)
68+
69+
assert result.exit_code == 0
70+
assert "Test argument string" in result.output
71+
assert "Code echo_args successful" in result.output
72+
73+
74+
def test_run_command_not_found(test_project):
75+
"""Test running a command that doesn't exist in config."""
76+
runner = CliRunner()
77+
result = runner.invoke(
78+
cli, ["run", "nonexistent", "--path", test_project, "--no-stream"]
79+
)
80+
81+
assert "Error: Command 'nonexistent' not found in codemcp.toml" in result.output
82+
83+
84+
def test_run_command_empty_definition(test_project):
85+
"""Test running a command with an empty definition."""
86+
runner = CliRunner()
87+
result = runner.invoke(
88+
cli, ["run", "invalid", "--path", test_project, "--no-stream"]
89+
)
90+
91+
assert "Error: Command 'invalid' not found in codemcp.toml" in result.output
92+
93+
94+
def test_run_command_stream_mode(test_project, monkeypatch):
95+
"""Test running a command with streaming mode."""
96+
import subprocess
97+
from unittest.mock import MagicMock
98+
99+
# Mock necessary asyncio functions to avoid actual repository operations
100+
async def mock_is_git_repo(*args, **kwargs):
101+
return False
102+
103+
monkeypatch.setattr("codemcp.git.is_git_repository", mock_is_git_repo)
104+
105+
# Create a mock for subprocess.Popen
106+
mock_process = MagicMock()
107+
mock_process.returncode = 0
108+
mock_process.wait.return_value = 0
109+
110+
# Keep track of Popen calls
111+
popen_calls = []
112+
113+
def mock_popen(cmd, **kwargs):
114+
popen_calls.append((cmd, kwargs))
115+
return mock_process
116+
117+
monkeypatch.setattr(subprocess, "Popen", mock_popen)
118+
119+
# Run the command
120+
runner = CliRunner()
121+
runner.invoke(cli, ["run", "echo", "--path", test_project])
122+
123+
# Check that our command was executed with the right parameters
124+
assert any(cmd == ["echo", "Hello from codemcp run!"] for cmd, _ in popen_calls)
125+
126+
# Find the call for our echo command
127+
for cmd, kwargs in popen_calls:
128+
if cmd == ["echo", "Hello from codemcp run!"]:
129+
# Verify streaming parameters
130+
assert kwargs.get("stdout") is None
131+
assert kwargs.get("stderr") is None
132+
assert kwargs.get("bufsize") == 0

0 commit comments

Comments
 (0)