From 1c55988637e5d29943dbd22148843d32c72cf1bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=B8=80?= Date: Tue, 19 May 2026 14:40:18 +0800 Subject: [PATCH 1/3] 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, , ...]`` 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-``). * Full repo: 1085 / 1085 passed, no regressions. Fixes #2629 Co-authored-by: Cursor --- .../integrations/cursor_agent/__init__.py | 31 ++++++++++- .../test_integration_cursor_agent.py | 52 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index 70af454ce9..7196c7fd08 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -2,6 +2,10 @@ Cursor Agent uses the ``.cursor/skills/speckit-/SKILL.md`` layout. Commands are deprecated; ``--skills`` defaults to ``True``. + +CLI dispatch via ``cursor-agent -p --trust `` is supported so +``specify workflow run`` can drive cursor-agent headlessly, in addition +to the existing in-IDE skill flow. """ from __future__ import annotations @@ -15,8 +19,8 @@ class CursorAgentIntegration(SkillsIntegration): "name": "Cursor", "folder": ".cursor/", "commands_subdir": "skills", - "install_url": None, - "requires_cli": False, + "install_url": "https://docs.cursor.com/en/cli/overview", + "requires_cli": True, } registrar_config = { "dir": ".cursor/skills", @@ -28,6 +32,29 @@ class CursorAgentIntegration(SkillsIntegration): context_file = ".cursor/rules/specify-rules.mdc" multi_install_safe = True + def build_exec_args( + self, + prompt: str, + *, + model: str | None = None, + output_json: bool = True, + ) -> list[str] | None: + """Build CLI arguments for non-interactive ``cursor-agent`` execution. + + Uses ``-p`` (print/headless mode, which gives access to all + tools including write and shell) plus ``--trust`` (bypass the + Workspace Trust prompt — mandatory for headless execution; the + CLI exits non-zero without it). + """ + if not self.config or not self.config.get("requires_cli"): + return None + args = [self.key, "-p", "--trust", prompt] + if model: + args.extend(["--model", model]) + if output_json: + args.extend(["--output-format", "json"]) + return args + @classmethod def options(cls) -> list[IntegrationOption]: return [ diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 352a0475b5..5c8e518c4a 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -106,3 +106,55 @@ def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path): assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}" assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() + +class TestCursorAgentCliDispatch: + """Verify the CLI dispatch path for cursor-agent (issue #2629). + + The ``cursor-agent`` CLI supports headless execution via ``-p`` (with + full tool access including write/shell) and requires ``--trust`` to + bypass the Workspace Trust prompt. These tests pin the exact argv + shape that the workflow runner will use. + """ + + def test_requires_cli_is_true(self): + i = get_integration("cursor-agent") + assert i.config.get("requires_cli") is True + + def test_install_url_is_set(self): + i = get_integration("cursor-agent") + url = i.config.get("install_url") + assert url is not None + assert "cursor.com" in url + + def test_build_exec_args_default_includes_trust_and_json(self): + i = get_integration("cursor-agent") + args = i.build_exec_args("/speckit-specify some-feature") + assert args == [ + "cursor-agent", "-p", "--trust", + "/speckit-specify some-feature", + "--output-format", "json", + ] + + def test_build_exec_args_text_output_omits_format(self): + i = get_integration("cursor-agent") + args = i.build_exec_args("/speckit-plan", output_json=False) + assert args == [ + "cursor-agent", "-p", "--trust", "/speckit-plan", + ] + + def test_build_exec_args_with_model(self): + i = get_integration("cursor-agent") + args = i.build_exec_args( + "/speckit-specify", model="sonnet-4-thinking", output_json=False + ) + assert args == [ + "cursor-agent", "-p", "--trust", "/speckit-specify", + "--model", "sonnet-4-thinking", + ] + + def test_build_command_invocation_uses_hyphenated_skill_name(self): + """SkillsIntegration: /speckit-plan (not /speckit.plan).""" + i = get_integration("cursor-agent") + assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x" + assert i.build_command_invocation("plan") == "/speckit-plan" + From 454d3d0bd1fbb4adf3287127f1f7637c2c032af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=B8=80?= Date: Tue, 19 May 2026 15:28:59 +0800 Subject: [PATCH 2/3] 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 --- src/specify_cli/integrations/base.py | 10 ++++ .../test_integration_cursor_agent.py | 52 +++++++++++++++++++ tests/test_workflows.py | 8 ++- 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7ce107caec..af0c73b40d 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -202,6 +202,16 @@ def dispatch_command( ) raise NotImplementedError(msg) + # Windows: ``subprocess.run`` calls ``CreateProcess`` which does not + # consult ``PATHEXT``, so a bare command name like ``cursor-agent`` + # that resolves to ``cursor-agent.cmd`` fails with ``WinError 2``. + # Resolve via ``shutil.which`` (which does honor ``PATHEXT``) so + # ``.cmd``/``.bat`` shims work transparently. On POSIX this is a + # no-op for absolute paths and a harmless lookup otherwise. + resolved = shutil.which(exec_args[0]) + if resolved: + exec_args = [resolved, *exec_args[1:]] + cwd = str(project_root) if project_root else None if stream: diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 5c8e518c4a..1e5c385ba4 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -158,3 +158,55 @@ def test_build_command_invocation_uses_hyphenated_skill_name(self): assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x" assert i.build_command_invocation("plan") == "/speckit-plan" + def test_dispatch_command_resolves_cmd_shim_for_subprocess(self): + """``.cmd`` shims must be resolved to their full path before ``subprocess.run``. + + ``cursor-agent`` (and other npm-installed CLIs on Windows) ship as + ``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT`` + and finds them, but Python's ``subprocess.run`` calls + ``CreateProcess`` which does **not** consult ``PATHEXT`` and fails + with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The + fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via + ``shutil.which`` so the full ``.cmd`` path is what reaches + ``CreateProcess``. + """ + from unittest.mock import patch, MagicMock + i = get_integration("cursor-agent") + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "ok" + mock_result.stderr = "" + + fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD" + with patch( + "specify_cli.integrations.base.shutil.which", return_value=fake_path + ), patch("subprocess.run", return_value=mock_result) as mock_run: + result = i.dispatch_command( + "speckit.plan", args="feature-x", stream=False, timeout=5 + ) + + assert result["exit_code"] == 0 + argv = mock_run.call_args[0][0] + assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}" + assert argv[1:4] == ["-p", "--trust", "/speckit-plan feature-x"] + + def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self): + """If ``shutil.which`` returns ``None``, leave argv unchanged so the + existing ``FileNotFoundError`` path remains observable to callers.""" + from unittest.mock import patch, MagicMock + i = get_integration("cursor-agent") + + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = "" + mock_result.stderr = "" + + with patch( + "specify_cli.integrations.base.shutil.which", return_value=None + ), patch("subprocess.run", return_value=mock_result) as mock_run: + i.dispatch_command("speckit.plan", stream=False, timeout=5) + + argv = mock_run.call_args[0][0] + assert argv[0] == "cursor-agent" + diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 3b42bf9106..1e06492fcb 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -555,15 +555,18 @@ def test_dispatch_with_mock_cli(self, tmp_path, monkeypatch): mock_result.stderr = "" with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \ patch("subprocess.run", return_value=mock_result) as mock_run: result = step.execute(config, ctx) assert result.status == StepStatus.COMPLETED assert result.output["dispatched"] is True assert result.output["exit_code"] == 0 - # Verify the CLI was called with -p and the skill invocation + # Verify the CLI was called with the resolved path (via shutil.which, + # which honors PATHEXT for ``.cmd``/``.bat`` shims on Windows), then + # ``-p`` and the skill invocation. call_args = mock_run.call_args - assert call_args[0][0][0] == "claude" + assert call_args[0][0][0] == "/usr/local/bin/claude" assert call_args[0][0][1] == "-p" # Claude is a SkillsIntegration so uses /speckit-specify assert "/speckit-specify login" in call_args[0][0][2] @@ -592,6 +595,7 @@ def test_dispatch_failure_returns_failed_status(self, tmp_path): mock_result.stderr = "API error" with patch("specify_cli.workflows.steps.command.shutil.which", return_value="/usr/local/bin/claude"), \ + patch("specify_cli.integrations.base.shutil.which", return_value="/usr/local/bin/claude"), \ patch("subprocess.run", return_value=mock_result): result = step.execute(config, ctx) From 595bcabea69f348eabec25ede53ab3c8d4873387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=B8=80?= Date: Tue, 19 May 2026 19:54:48 +0800 Subject: [PATCH 3/3] 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 --- .../integrations/cursor_agent/__init__.py | 26 ++++++++++++---- .../test_integration_cursor_agent.py | 30 +++++++++++++++---- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index 7196c7fd08..251d1301ed 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -41,14 +41,30 @@ def build_exec_args( ) -> list[str] | None: """Build CLI arguments for non-interactive ``cursor-agent`` execution. - Uses ``-p`` (print/headless mode, which gives access to all - tools including write and shell) plus ``--trust`` (bypass the - Workspace Trust prompt — mandatory for headless execution; the - CLI exits non-zero without it). + Mandatory headless flags: + + * ``-p`` — print/headless mode (access to all tools) + * ``--trust`` — bypass Workspace Trust prompt (CLI exits non-zero + otherwise) + * ``--approve-mcps`` — auto-approve MCP server loading (otherwise + MCP servers stay ``not loaded (needs approval)`` and tool calls + to them are silently dropped) + * ``--force`` — auto-approve tool invocations (shell/write/MCP), + matching the implicit "trusted environment" semantics that other + integrations (``claude -p``, ``codex --exec``) get by default + + Together these are the minimum set required to make + ``specify workflow run speckit --input integration=cursor-agent`` + behave the same way as it does for ``claude`` / ``codex``. + Verified locally: with ``--approve-mcps --force`` the agent can + call any configured MCP server (e.g. ``dingtalk-doc``) and write + files during ``/speckit-*`` skill execution; without them the run + either drops tool calls or exits non-zero on the first approval + prompt. """ if not self.config or not self.config.get("requires_cli"): return None - args = [self.key, "-p", "--trust", prompt] + args = [self.key, "-p", "--trust", "--approve-mcps", "--force", prompt] if model: args.extend(["--model", model]) if output_json: diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 1e5c385ba4..023f2dfc1b 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -126,11 +126,14 @@ def test_install_url_is_set(self): assert url is not None assert "cursor.com" in url - def test_build_exec_args_default_includes_trust_and_json(self): + def test_build_exec_args_default_includes_headless_flags_and_json(self): + """Default argv emits the full headless flag set: -p --trust + --approve-mcps --force, then prompt, then --output-format json. + """ i = get_integration("cursor-agent") args = i.build_exec_args("/speckit-specify some-feature") assert args == [ - "cursor-agent", "-p", "--trust", + "cursor-agent", "-p", "--trust", "--approve-mcps", "--force", "/speckit-specify some-feature", "--output-format", "json", ] @@ -139,7 +142,8 @@ def test_build_exec_args_text_output_omits_format(self): i = get_integration("cursor-agent") args = i.build_exec_args("/speckit-plan", output_json=False) assert args == [ - "cursor-agent", "-p", "--trust", "/speckit-plan", + "cursor-agent", "-p", "--trust", "--approve-mcps", "--force", + "/speckit-plan", ] def test_build_exec_args_with_model(self): @@ -148,10 +152,26 @@ def test_build_exec_args_with_model(self): "/speckit-specify", model="sonnet-4-thinking", output_json=False ) assert args == [ - "cursor-agent", "-p", "--trust", "/speckit-specify", + "cursor-agent", "-p", "--trust", "--approve-mcps", "--force", + "/speckit-specify", "--model", "sonnet-4-thinking", ] + def test_build_exec_args_contains_mandatory_headless_flags(self): + """The four headless flags must always appear together. + + ``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc) + actually load in headless mode; ``--force`` is required so the + agent doesn't block on tool-call approval prompts during the + speckit workflow. Together with ``-p`` and ``--trust`` they + bring cursor-agent's headless behaviour in line with + ``claude -p`` / ``codex --exec`` from spec-kit's perspective. + """ + i = get_integration("cursor-agent") + args = i.build_exec_args("/speckit-implement", output_json=False) + for flag in ("-p", "--trust", "--approve-mcps", "--force"): + assert flag in args, f"missing mandatory headless flag: {flag}" + def test_build_command_invocation_uses_hyphenated_skill_name(self): """SkillsIntegration: /speckit-plan (not /speckit.plan).""" i = get_integration("cursor-agent") @@ -189,7 +209,7 @@ def test_dispatch_command_resolves_cmd_shim_for_subprocess(self): assert result["exit_code"] == 0 argv = mock_run.call_args[0][0] assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}" - assert argv[1:4] == ["-p", "--trust", "/speckit-plan feature-x"] + assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"] def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self): """If ``shutil.which`` returns ``None``, leave argv unchanged so the