Skip to content

Commit 3aaa4a8

Browse files
committed
feat: add codemcp 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. However, do NOT reuse RunCommand's code (which is tuned for MCP usage), instead reimplement the subprocess call code correctly. stdin/stdout/stderr should be inherited. Do NOT do git commits. Just run the command. ```git-revs 292b66b (Base revision) 0015442 Add 'run' command to CLI to execute commands from codemcp.toml without git commits adba9fb Add end-to-end tests for the new 'run' command 375e216 Auto-commit format changes ed25816 Auto-commit lint changes d536fef Add sys import to test_run_command.py 4daf9cb Update test_run_command_exists to use sys.executable 7e2369e Update test_run_command_basic to use sys.executable 41ff40d Update test_run_command_with_args to use sys.executable 83691fe Update test_run_command_error_exit_code to use sys.executable 189dcdf Update test_run_command_missing_command to use sys.executable 6f476f5 Return non-zero exit code when command is not found in codemcp.toml HEAD Return non-zero exit code for invalid command configuration ``` codemcp-id: 278-feat-add-codemcp-run-command-to-cli ghstack-source-id: 4f384bd Pull-Request-resolved: #271
1 parent ab13daa commit 3aaa4a8

File tree

2 files changed

+185
-0
lines changed

2 files changed

+185
-0
lines changed

codemcp/main.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,99 @@ def init(path: str, python: bool) -> None:
825825
click.echo(result)
826826

827827

828+
@cli.command()
829+
@click.argument("command", type=str)
830+
@click.argument("args", nargs=-1)
831+
@click.option("--path", type=click.Path(), default=".", help="Project directory path")
832+
def run(command: str, args: tuple, path: str) -> None:
833+
"""Run a command defined in codemcp.toml without doing git commits.
834+
835+
The command should be defined in the [commands] section of codemcp.toml.
836+
Any additional arguments are passed to the command.
837+
838+
Examples:
839+
codemcp run format
840+
codemcp run test path/to/test_file.py
841+
"""
842+
import asyncio
843+
import os
844+
import subprocess
845+
846+
import tomli
847+
848+
from .common import normalize_file_path
849+
850+
# Handle the async nature of the function in a sync context
851+
asyncio.get_event_loop()
852+
853+
# Normalize path
854+
full_path = normalize_file_path(path)
855+
856+
# Check if path exists
857+
if not os.path.exists(full_path):
858+
click.echo(f"Error: Path {path} does not exist", err=True)
859+
return
860+
861+
# Check if it's a directory
862+
if not os.path.isdir(full_path):
863+
click.echo(f"Error: Path {path} is not a directory", err=True)
864+
return
865+
866+
# Check for codemcp.toml file
867+
config_path = os.path.join(full_path, "codemcp.toml")
868+
if not os.path.exists(config_path):
869+
click.echo(f"Error: Config file not found: {config_path}", err=True)
870+
return
871+
872+
# Load command from config
873+
try:
874+
with open(config_path, "rb") as f:
875+
config = tomli.load(f)
876+
877+
if "commands" not in config or command not in config["commands"]:
878+
click.echo(
879+
f"Error: Command '{command}' not found in codemcp.toml", err=True
880+
)
881+
exit(1) # Exit with error code 1
882+
883+
cmd_config = config["commands"][command]
884+
cmd_list = None
885+
886+
# Handle both direct command lists and dictionaries with 'command' field
887+
if isinstance(cmd_config, list):
888+
cmd_list = cmd_config
889+
elif isinstance(cmd_config, dict) and "command" in cmd_config:
890+
cmd_list = cmd_config["command"]
891+
else:
892+
click.echo(
893+
f"Error: Invalid command configuration for '{command}'", err=True
894+
)
895+
exit(1) # Exit with error code 1
896+
897+
# Add additional arguments if provided
898+
if args:
899+
cmd_list = list(cmd_list) + list(args)
900+
901+
# Run the command with inherited stdin/stdout/stderr
902+
process = subprocess.run(
903+
cmd_list,
904+
cwd=full_path,
905+
stdin=None, # inherit
906+
stdout=None, # inherit
907+
stderr=None, # inherit
908+
text=True,
909+
check=False, # Don't raise exception on non-zero exit codes
910+
)
911+
912+
# Return the exit code
913+
if process.returncode != 0:
914+
exit(process.returncode)
915+
916+
except Exception as e:
917+
click.echo(f"Error executing command: {e}", err=True)
918+
exit(1)
919+
920+
828921
def create_sse_app(allowed_origins: Optional[List[str]] = None) -> Starlette:
829922
"""Create an SSE app with the MCP server.
830923

e2e/test_run_command.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import subprocess
5+
import sys
6+
import tempfile
7+
8+
import pytest
9+
10+
from codemcp.main import init_codemcp_project
11+
12+
13+
@pytest.fixture
14+
def project_dir():
15+
"""Create a temporary project directory with a simple codemcp.toml configuration."""
16+
with tempfile.TemporaryDirectory() as temp_dir:
17+
# Initialize the project
18+
init_codemcp_project(temp_dir)
19+
20+
# Create a codemcp.toml file with test commands
21+
config_path = os.path.join(temp_dir, "codemcp.toml")
22+
with open(config_path, "w") as f:
23+
f.write("""
24+
[commands]
25+
echo = ["echo", "Hello World"]
26+
list = ["ls", "-la"]
27+
exit_with_error = ["bash", "-c", "exit 1"]
28+
""")
29+
30+
yield temp_dir
31+
32+
33+
def test_run_command_exists():
34+
"""Test that the 'run' command exists and is listed in help output."""
35+
result = subprocess.run(
36+
[sys.executable, "-m", "codemcp", "--help"],
37+
capture_output=True,
38+
text=True,
39+
check=True,
40+
)
41+
assert "run" in result.stdout
42+
43+
44+
def test_run_command_basic(project_dir):
45+
"""Test running a basic command that outputs to stdout."""
46+
result = subprocess.run(
47+
[sys.executable, "-m", "codemcp", "run", "echo", "--path", project_dir],
48+
capture_output=True,
49+
text=True,
50+
check=True,
51+
)
52+
assert "Hello World" in result.stdout
53+
54+
55+
def test_run_command_with_args(project_dir):
56+
"""Test running a command with additional arguments that override defaults."""
57+
# Create a file to list
58+
test_file = os.path.join(project_dir, "test_file.txt")
59+
with open(test_file, "w") as f:
60+
f.write("test content")
61+
62+
result = subprocess.run(
63+
[sys.executable, "-m", "codemcp", "run", "list", test_file, "--path", project_dir],
64+
capture_output=True,
65+
text=True,
66+
check=True,
67+
)
68+
assert "test_file.txt" in result.stdout
69+
70+
71+
def test_run_command_error_exit_code(project_dir):
72+
"""Test that error exit codes from the command are propagated."""
73+
# This should return a non-zero exit code
74+
process = subprocess.run(
75+
[sys.executable, "-m", "codemcp", "run", "exit_with_error", "--path", project_dir],
76+
capture_output=True,
77+
text=True,
78+
check=False,
79+
)
80+
assert process.returncode != 0
81+
82+
83+
def test_run_command_missing_command(project_dir):
84+
"""Test running a command that doesn't exist in codemcp.toml."""
85+
process = subprocess.run(
86+
[sys.executable, "-m", "codemcp", "run", "nonexistent", "--path", project_dir],
87+
capture_output=True,
88+
text=True,
89+
check=False,
90+
)
91+
assert process.returncode != 0
92+
assert "not found in codemcp.toml" in process.stderr

0 commit comments

Comments
 (0)