Skip to content

Commit 8e5643d

Browse files
One-TheOnly刘一cursoragentCopilot
authored
fix(cursor-agent): enable headless CLI dispatch end-to-end (-p --trust --approve-mcps --force + Windows .cmd shim resolution) (#2631)
* fix(cursor-agent): enable CLI dispatch via ``-p --trust`` headless mode Restores the ability for ``specify workflow run`` to dispatch the cursor-agent CLI, complementing the existing in-IDE skill flow. Without this fix, ``specify workflow run speckit --input integration=cursor-agent ...`` fails with a misleading ``CLI not found or not installed`` error even when the CLI is installed (since cursor-agent had ``requires_cli=False`` and an unset ``build_exec_args``). The cursor-agent CLI (>= 2026.05.16) supports headless execution via ``-p`` (print mode with full tool access including write/shell) and ``--trust`` (bypass Workspace Trust prompt). Without ``--trust`` the CLI exits non-zero in non-TTY contexts (verified locally). Changes to ``src/specify_cli/integrations/cursor_agent/__init__.py``: * ``config.requires_cli``: ``False`` -> ``True`` * ``config.install_url``: ``None`` -> Cursor CLI docs URL * Override ``build_exec_args()`` to emit ``[cursor-agent, -p, --trust, <prompt>, ...]`` with optional ``--model`` and ``--output-format json`` flags, mirroring the shape used by ``claude``/``codex``/``gemini``. Tests: * 34 existing cursor-agent tests still pass. * 6 new tests in ``TestCursorAgentCliDispatch`` pin ``requires_cli``, ``install_url``, and the exact argv shape (default, text-output, with-model, and the hyphenated skill invocation form ``/speckit-<name>``). * Full repo: 1085 / 1085 passed, no regressions. Fixes #2629 Co-authored-by: Cursor <cursoragent@cursor.com> * 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> * fix(cursor-agent): inject --approve-mcps --force for headless MCP/tool access The previous commit (1c55988) wired up ``-p --trust`` so the CLI launches in headless mode without the Workspace Trust prompt, but that alone is not enough to let ``specify workflow run`` drive a real speckit feature end-to-end with cursor-agent on Windows. Two more flags are required: * ``--approve-mcps``: without it, every MCP server configured in ``.cursor/mcp.json`` stays ``not loaded (needs approval)``, and any tool call against them is silently dropped. We hit this immediately trying to read a DingTalk PRD from a remote MCP server during the ``/speckit-specify`` step. * ``--force``: without it, the agent halts on the first tool-call approval prompt (the tool call gets rejected and the workflow exits non-zero with a misleading message). With ``--force`` cursor-agent matches the implicit "trusted environment" semantics that ``claude -p`` and ``codex --exec`` already have by default -- which is the right semantics for an unattended ``specify workflow run`` invocation. Verified end-to-end on Windows 10 + cursor-agent 2026.05.16-0338208: * ``cursor-agent -p --trust --approve-mcps --force --output-format text`` + a ``/speckit-specify`` prompt that included a DingTalk URL produced a full spec.md (31.5 KB) plus checklists/requirements.md in ~10.7 min, reading the source PRD through the ``dingtalk-doc`` remote MCP server, deciding the ``specs/`` subpath itself, and updating ``.specify/feature.json`` and ``specs/menu-dictionary.md`` along the way -- no human-in-the-loop, no source PRD ever touched the filesystem. * Without ``--approve-mcps`` the same prompt errors with the tool call rejected message; without ``--force`` the agent stops at the first non-MCP tool call. Tests: * ``test_build_exec_args_*`` updated to pin the new four-flag prefix. * New ``test_build_exec_args_contains_mandatory_headless_flags`` asserts the four flags are always present together. * ``test_dispatch_command_resolves_cmd_shim_for_subprocess`` updated to match the new argv layout. * All 43 cursor-agent tests pass; no other tests touched. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(cursor-agent): express dispatch support via build_exec_args() instead of requires_cli Co-authored-by: Cursor <cursoragent@cursor.com> * test(cursor-agent): use urlparse hostname check and cover dispatch without requires_cli Co-authored-by: Cursor <cursoragent@cursor.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: 刘一 <liuyi@oureman.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 3a67dad commit 8e5643d

4 files changed

Lines changed: 227 additions & 3 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,16 @@ def dispatch_command(
270270
)
271271
raise NotImplementedError(msg)
272272

273+
# Windows: ``subprocess.run`` calls ``CreateProcess`` which does not
274+
# consult ``PATHEXT``, so a bare command name like ``cursor-agent``
275+
# that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``.
276+
# Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so
277+
# ``.cmd``/``.bat`` shims work transparently. On POSIX this is a
278+
# no-op for absolute paths and a harmless lookup otherwise.
279+
resolved = shutil.which(exec_args[0])
280+
if resolved:
281+
exec_args = [resolved, *exec_args[1:]]
282+
273283
cwd = str(project_root) if project_root else None
274284

275285
if stream:

src/specify_cli/integrations/cursor_agent/__init__.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
33
Cursor Agent uses the ``.cursor/skills/speckit-<name>/SKILL.md`` layout.
44
Commands are deprecated; ``--skills`` defaults to ``True``.
5+
6+
The IDE/skills flow is the primary path and works without the
7+
``cursor-agent`` CLI being installed (``requires_cli=False``). Workflow
8+
dispatch via ``cursor-agent -p --trust --approve-mcps --force <prompt>``
9+
is offered as an opt-in capability — the presence of ``build_exec_args()``
10+
is what indicates dispatch support, mirroring ``CopilotIntegration``.
511
"""
612

713
from __future__ import annotations
@@ -15,7 +21,12 @@ class CursorAgentIntegration(SkillsIntegration):
1521
"name": "Cursor",
1622
"folder": ".cursor/",
1723
"commands_subdir": "skills",
18-
"install_url": None,
24+
"install_url": "https://docs.cursor.com/en/cli/overview",
25+
# IDE-first integration: ``specify init --ai cursor-agent`` must
26+
# work without the ``cursor-agent`` CLI installed (the IDE flow
27+
# uses skills directly). Workflow dispatch additionally requires
28+
# the CLI on PATH, but that's enforced at dispatch time via
29+
# ``shutil.which`` rather than as a hard ``specify init`` precheck.
1930
"requires_cli": False,
2031
}
2132
registrar_config = {
@@ -28,6 +39,50 @@ class CursorAgentIntegration(SkillsIntegration):
2839
context_file = ".cursor/rules/specify-rules.mdc"
2940
multi_install_safe = True
3041

42+
def build_exec_args(
43+
self,
44+
prompt: str,
45+
*,
46+
model: str | None = None,
47+
output_json: bool = True,
48+
) -> list[str] | None:
49+
"""Build CLI arguments for non-interactive ``cursor-agent`` execution.
50+
51+
Always returns argv (no ``requires_cli`` guard) so workflow
52+
dispatch is supported even though the integration's ``config``
53+
sets ``requires_cli=False`` to keep the IDE-only flow unblocked.
54+
This mirrors ``CopilotIntegration``: dispatch support is signalled
55+
by overriding ``build_exec_args()``, not by the ``requires_cli``
56+
flag (which is reserved for the ``specify init`` precheck).
57+
58+
Mandatory headless flags:
59+
60+
* ``-p`` — print/headless mode (access to all tools)
61+
* ``--trust`` — bypass Workspace Trust prompt (CLI exits non-zero
62+
otherwise)
63+
* ``--approve-mcps`` — auto-approve MCP server loading (otherwise
64+
MCP servers stay ``not loaded (needs approval)`` and tool calls
65+
to them are silently dropped)
66+
* ``--force`` — auto-approve tool invocations (shell/write/MCP),
67+
matching the implicit "trusted environment" semantics that other
68+
integrations (``claude -p``, ``codex --exec``) get by default
69+
70+
Together these are the minimum set required to make
71+
``specify workflow run speckit --input integration=cursor-agent``
72+
behave the same way as it does for ``claude`` / ``codex``.
73+
Verified locally: with ``--approve-mcps --force`` the agent can
74+
call any configured MCP server (e.g. ``dingtalk-doc``) and write
75+
files during ``/speckit-*`` skill execution; without them the run
76+
either drops tool calls or exits non-zero on the first approval
77+
prompt.
78+
"""
79+
args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt]
80+
if model:
81+
args.extend(["--model", model])
82+
if output_json:
83+
args.extend(["--output-format", "json"])
84+
return args
85+
3186
@classmethod
3287
def options(cls) -> list[IntegrationOption]:
3388
return [

tests/integrations/test_integration_cursor_agent.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for CursorAgentIntegration."""
22

33
from pathlib import Path
4+
from urllib.parse import urlparse
45

56
from specify_cli.integrations import get_integration
67
from specify_cli.integrations.manifest import IntegrationManifest
@@ -106,3 +107,157 @@ def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
106107
assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}"
107108
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
108109

110+
111+
class TestCursorAgentCliDispatch:
112+
"""Verify the CLI dispatch path for cursor-agent (issue #2629).
113+
114+
The ``cursor-agent`` CLI supports headless execution via ``-p`` (with
115+
full tool access including write/shell) and requires ``--trust`` to
116+
bypass the Workspace Trust prompt. These tests pin the exact argv
117+
shape that the workflow runner will use.
118+
"""
119+
120+
def test_requires_cli_is_false_for_ide_first_flow(self):
121+
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
122+
123+
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``)
124+
treats ``requires_cli=True`` as a hard precheck and fails when the
125+
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
126+
/ skills flow can run without it. Workflow dispatch support is
127+
signalled by overriding ``build_exec_args()`` instead, mirroring
128+
``CopilotIntegration``.
129+
"""
130+
i = get_integration("cursor-agent")
131+
assert i.config.get("requires_cli") is False
132+
133+
def test_install_url_is_set(self):
134+
i = get_integration("cursor-agent")
135+
url = i.config.get("install_url")
136+
assert url is not None
137+
# CodeQL: use a hostname comparison instead of a substring check
138+
# to avoid the "Incomplete URL substring sanitization" warning
139+
# (substring "cursor.com" can also appear in attacker-controlled
140+
# positions of an arbitrary URL).
141+
host = (urlparse(url).hostname or "").lower()
142+
assert host == "cursor.com" or host.endswith(".cursor.com")
143+
144+
def test_build_exec_args_default_includes_headless_flags_and_json(self):
145+
"""Default argv emits the full headless flag set: -p --trust
146+
--approve-mcps --force, then prompt, then --output-format json.
147+
"""
148+
i = get_integration("cursor-agent")
149+
args = i.build_exec_args("/speckit-specify some-feature")
150+
assert args == [
151+
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
152+
"/speckit-specify some-feature",
153+
"--output-format", "json",
154+
]
155+
156+
def test_build_exec_args_text_output_omits_format(self):
157+
i = get_integration("cursor-agent")
158+
args = i.build_exec_args("/speckit-plan", output_json=False)
159+
assert args == [
160+
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
161+
"/speckit-plan",
162+
]
163+
164+
def test_build_exec_args_with_model(self):
165+
i = get_integration("cursor-agent")
166+
args = i.build_exec_args(
167+
"/speckit-specify", model="sonnet-4-thinking", output_json=False
168+
)
169+
assert args == [
170+
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
171+
"/speckit-specify",
172+
"--model", "sonnet-4-thinking",
173+
]
174+
175+
def test_build_exec_args_contains_mandatory_headless_flags(self):
176+
"""The four headless flags must always appear together.
177+
178+
``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc)
179+
actually load in headless mode; ``--force`` is required so the
180+
agent doesn't block on tool-call approval prompts during the
181+
speckit workflow. Together with ``-p`` and ``--trust`` they
182+
bring cursor-agent's headless behaviour in line with
183+
``claude -p`` / ``codex --exec`` from spec-kit's perspective.
184+
"""
185+
i = get_integration("cursor-agent")
186+
args = i.build_exec_args("/speckit-implement", output_json=False)
187+
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
188+
assert flag in args, f"missing mandatory headless flag: {flag}"
189+
190+
def test_build_exec_args_supports_dispatch_without_requires_cli(self):
191+
"""``build_exec_args`` must return argv even though ``requires_cli``
192+
is ``False``.
193+
194+
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
195+
precheck (so ``specify init`` doesn't fail when the CLI isn't on
196+
PATH) but still supports workflow dispatch. The presence of a
197+
non-``None`` argv from ``build_exec_args()`` is what the engine
198+
keys off — pin that invariant.
199+
"""
200+
i = get_integration("cursor-agent")
201+
assert i.config.get("requires_cli") is False
202+
argv = i.build_exec_args("/speckit-plan", output_json=False)
203+
assert argv is not None
204+
assert argv[0] == "cursor-agent"
205+
206+
def test_build_command_invocation_uses_hyphenated_skill_name(self):
207+
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
208+
i = get_integration("cursor-agent")
209+
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
210+
assert i.build_command_invocation("plan") == "/speckit-plan"
211+
212+
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
213+
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
214+
215+
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
216+
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
217+
and finds them, but Python's ``subprocess.run`` calls
218+
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
219+
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
220+
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
221+
``shutil.which`` so the full ``.cmd`` path is what reaches
222+
``CreateProcess``.
223+
"""
224+
from unittest.mock import patch, MagicMock
225+
i = get_integration("cursor-agent")
226+
227+
mock_result = MagicMock()
228+
mock_result.returncode = 0
229+
mock_result.stdout = "ok"
230+
mock_result.stderr = ""
231+
232+
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
233+
with patch(
234+
"specify_cli.integrations.base.shutil.which", return_value=fake_path
235+
), patch("subprocess.run", return_value=mock_result) as mock_run:
236+
result = i.dispatch_command(
237+
"speckit.plan", args="feature-x", stream=False, timeout=5
238+
)
239+
240+
assert result["exit_code"] == 0
241+
argv = mock_run.call_args[0][0]
242+
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
243+
assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"]
244+
245+
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
246+
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
247+
existing ``FileNotFoundError`` path remains observable to callers."""
248+
from unittest.mock import patch, MagicMock
249+
i = get_integration("cursor-agent")
250+
251+
mock_result = MagicMock()
252+
mock_result.returncode = 0
253+
mock_result.stdout = ""
254+
mock_result.stderr = ""
255+
256+
with patch(
257+
"specify_cli.integrations.base.shutil.which", return_value=None
258+
), patch("subprocess.run", return_value=mock_result) as mock_run:
259+
i.dispatch_command("speckit.plan", stream=False, timeout=5)
260+
261+
argv = mock_run.call_args[0][0]
262+
assert argv[0] == "cursor-agent"
263+

tests/test_workflows.py

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

603603
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
604+
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
604605
patch("subprocess.run", return_value=mock_result) as mock_run:
605606
result = step.execute(config, ctx)
606607

607608
assert result.status == StepStatus.COMPLETED
608609
assert result.output["dispatched"] is True
609610
assert result.output["exit_code"] == 0
610-
# Verify the CLI was called with -p and the skill invocation
611+
# Verify the CLI was called with the resolved path (via shutil.which,
612+
# which honors PATHEXT for ``.cmd``/``.bat`` shims on Windows), then
613+
# ``-p`` and the skill invocation.
611614
call_args = mock_run.call_args
612-
assert call_args[0][0][0] == "claude"
615+
assert call_args[0][0][0] == "/usr/local/bin/claude"
613616
assert call_args[0][0][1] == "-p"
614617
# Claude is a SkillsIntegration so uses /speckit-specify
615618
assert "/speckit-specify login" in call_args[0][0][2]
@@ -638,6 +641,7 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path):
638641
mock_result.stderr = "API error"
639642

640643
with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \
644+
patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \
641645
patch("subprocess.run", return_value=mock_result):
642646
result = step.execute(config, ctx)
643647

0 commit comments

Comments
 (0)