|
1 | 1 | """Tests for CursorAgentIntegration.""" |
2 | 2 |
|
3 | 3 | from pathlib import Path |
| 4 | +from urllib.parse import urlparse |
4 | 5 |
|
5 | 6 | from specify_cli.integrations import get_integration |
6 | 7 | from specify_cli.integrations.manifest import IntegrationManifest |
@@ -106,3 +107,157 @@ def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path): |
106 | 107 | assert result.exit_code == 0, f"init --ai cursor-agent failed: {result.output}" |
107 | 108 | assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists() |
108 | 109 |
|
| 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 | + |
0 commit comments