Skip to content

Commit 454d3d0

Browse files
刘一cursoragent
andcommitted
fix(integrations): resolve .cmd/.bat shims before subprocess.run
On Windows, ``shutil.which`` honors ``PATHEXT`` and locates wrappers like ``cursor-agent.cmd`` and ``codex.cmd``, but Python's ``subprocess.run`` calls ``CreateProcess`` which does **not** consult ``PATHEXT`` and therefore fails with ``WinError 2`` on a bare argv like ``[cursor-agent, ...]``. Resolve ``exec_args[0]`` via ``shutil.which`` in ``IntegrationBase.dispatch_command`` so ``.cmd``/``.bat`` shims work transparently. On POSIX this is a no-op for absolute paths and a harmless lookup otherwise. Verified locally on Windows 10 + cursor-agent 2026.05.16: without this fix, ``specify workflow run speckit --input integration=cursor-agent`` fails with ``FileNotFoundError`` even after the cursor-agent integration starts producing valid exec args (per the prior commit on this branch). Tests: * New: 2 cursor-agent tests pin the shim-resolution + passthrough behavior (``test_dispatch_command_resolves_cmd_shim_for_subprocess`` and ``test_dispatch_command_passthrough_when_shutil_which_finds_nothing``). * Updated: ``tests/test_workflows.py::TestCommandStep::test_dispatch_with_mock_cli`` was mocking ``shutil.which`` only at the ``command`` step level and not at the ``base`` level, which made it environment-sensitive (fails locally when the real ``claude`` CLI is on PATH). Added the matching base-level patch and updated the argv-assertion to reflect the resolved path. ``test_dispatch_failure_returns_failed_status`` gets the same patch for consistency. * Full repo: 2867 passed, 0 regression from this PR. The 12 remaining pre-existing failures are unrelated Windows ``symlink`` privilege failures (``WinError 1314``) on a non-admin Windows runner. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 1c55988 commit 454d3d0

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,16 @@ def dispatch_command(
202202
)
203203
raise NotImplementedError(msg)
204204

205+
# Windows: ``subprocess.run`` calls ``CreateProcess`` which does not
206+
# consult ``PATHEXT``, so a bare command name like ``cursor-agent``
207+
# that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``.
208+
# Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so
209+
# ``.cmd``/``.bat`` shims work transparently. On POSIX this is a
210+
# no-op for absolute paths and a harmless lookup otherwise.
211+
resolved = shutil.which(exec_args[0])
212+
if resolved:
213+
exec_args = [resolved, *exec_args[1:]]
214+
205215
cwd = str(project_root) if project_root else None
206216

207217
if stream:

tests/integrations/test_integration_cursor_agent.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,55 @@ def test_build_command_invocation_uses_hyphenated_skill_name(self):
158158
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
159159
assert i.build_command_invocation("plan") == "/speckit-plan"
160160

161+
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
162+
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
163+
164+
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
165+
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
166+
and finds them, but Python's ``subprocess.run`` calls
167+
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
168+
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
169+
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
170+
``shutil.which`` so the full ``.cmd`` path is what reaches
171+
``CreateProcess``.
172+
"""
173+
from unittest.mock import patch, MagicMock
174+
i = get_integration("cursor-agent")
175+
176+
mock_result = MagicMock()
177+
mock_result.returncode = 0
178+
mock_result.stdout = "ok"
179+
mock_result.stderr = ""
180+
181+
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
182+
with patch(
183+
"specify_cli.integrations.base.shutil.which", return_value=fake_path
184+
), patch("subprocess.run", return_value=mock_result) as mock_run:
185+
result = i.dispatch_command(
186+
"speckit.plan", args="feature-x", stream=False, timeout=5
187+
)
188+
189+
assert result["exit_code"] == 0
190+
argv = mock_run.call_args[0][0]
191+
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
192+
assert argv[1:4] == ["-p", "--trust", "/speckit-plan feature-x"]
193+
194+
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
195+
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
196+
existing ``FileNotFoundError`` path remains observable to callers."""
197+
from unittest.mock import patch, MagicMock
198+
i = get_integration("cursor-agent")
199+
200+
mock_result = MagicMock()
201+
mock_result.returncode = 0
202+
mock_result.stdout = ""
203+
mock_result.stderr = ""
204+
205+
with patch(
206+
"specify_cli.integrations.base.shutil.which", return_value=None
207+
), patch("subprocess.run", return_value=mock_result) as mock_run:
208+
i.dispatch_command("speckit.plan", stream=False, timeout=5)
209+
210+
argv = mock_run.call_args[0][0]
211+
assert argv[0] == "cursor-agent"
212+

tests/test_workflows.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -555,15 +555,18 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch):
555555
mock_result.stderr = ""
556556

557557
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
558+
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
558559
patch("subprocess.run", return_value=mock_result) as mock_run:
559560
result = step.execute(config, ctx)
560561

561562
assert result.status == StepStatus.COMPLETED
562563
assert result.output["dispatched"] is True
563564
assert result.output["exit_code"] == 0
564-
# Verify the CLI was called with -p and the skill invocation
565+
# Verify the CLI was called with the resolved path (via shutil.which,
566+
# which honors PATHEXT for ``.cmd``/``.bat`` shims on Windows), then
567+
# ``-p`` and the skill invocation.
565568
call_args = mock_run.call_args
566-
assert call_args[0][0][0] == "claude"
569+
assert call_args[0][0][0] == "/usr/local/bin/claude"
567570
assert call_args[0][0][1] == "-p"
568571
# Claude is a SkillsIntegration so uses /speckit-specify
569572
assert "/speckit-specify login" in call_args[0][0][2]
@@ -592,6 +595,7 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path):
592595
mock_result.stderr = "API error"
593596

594597
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
598+
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
595599
patch("subprocess.run", return_value=mock_result):
596600
result = step.execute(config, ctx)
597601

0 commit comments

Comments
 (0)