mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 20:36:23 +08:00
* 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>
264 lines
11 KiB
Python
264 lines
11 KiB
Python
"""Tests for CursorAgentIntegration."""
|
|
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from specify_cli.integrations import get_integration
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
from .test_integration_base_skills import SkillsIntegrationTests
|
|
|
|
|
|
class TestCursorAgentIntegration(SkillsIntegrationTests):
|
|
KEY = "cursor-agent"
|
|
FOLDER = ".cursor/"
|
|
COMMANDS_SUBDIR = "skills"
|
|
REGISTRAR_DIR = ".cursor/skills"
|
|
CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
|
|
|
|
|
|
class TestCursorMdcFrontmatter:
|
|
"""Verify .mdc frontmatter handling in upsert/remove context section."""
|
|
|
|
def _setup(self, tmp_path: Path):
|
|
i = get_integration("cursor-agent")
|
|
m = IntegrationManifest("cursor-agent", tmp_path)
|
|
return i, m
|
|
|
|
def test_new_mdc_gets_frontmatter(self, tmp_path):
|
|
"""A freshly created .mdc file includes alwaysApply: true."""
|
|
i, m = self._setup(tmp_path)
|
|
i.setup(tmp_path, m)
|
|
ctx = (tmp_path / i.context_file).read_text(encoding="utf-8")
|
|
assert ctx.startswith("---\n")
|
|
assert "alwaysApply: true" in ctx
|
|
|
|
def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path):
|
|
"""An existing .mdc without frontmatter gets it added."""
|
|
i, m = self._setup(tmp_path)
|
|
ctx_path = tmp_path / i.context_file
|
|
ctx_path.parent.mkdir(parents=True, exist_ok=True)
|
|
ctx_path.write_text("# User rules\n", encoding="utf-8")
|
|
i.upsert_context_section(tmp_path)
|
|
content = ctx_path.read_text(encoding="utf-8")
|
|
assert content.lstrip().startswith("---")
|
|
assert "alwaysApply: true" in content
|
|
assert "# User rules" in content
|
|
|
|
def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path):
|
|
"""An existing .mdc with custom frontmatter is preserved."""
|
|
i, m = self._setup(tmp_path)
|
|
ctx_path = tmp_path / i.context_file
|
|
ctx_path.parent.mkdir(parents=True, exist_ok=True)
|
|
ctx_path.write_text(
|
|
"---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n",
|
|
encoding="utf-8",
|
|
)
|
|
i.upsert_context_section(tmp_path)
|
|
content = ctx_path.read_text(encoding="utf-8")
|
|
assert "alwaysApply: true" in content
|
|
assert "customKey: hello" in content
|
|
assert "<!-- SPECKIT START -->" in content
|
|
|
|
def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path):
|
|
"""An .mdc with alwaysApply: false gets corrected."""
|
|
i, m = self._setup(tmp_path)
|
|
ctx_path = tmp_path / i.context_file
|
|
ctx_path.parent.mkdir(parents=True, exist_ok=True)
|
|
ctx_path.write_text(
|
|
"---\nalwaysApply: false\n---\n\n# Rules\n",
|
|
encoding="utf-8",
|
|
)
|
|
i.upsert_context_section(tmp_path)
|
|
content = ctx_path.read_text(encoding="utf-8")
|
|
assert "alwaysApply: true" in content
|
|
assert "alwaysApply: false" not in content
|
|
|
|
def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path):
|
|
"""Repeated upserts don't duplicate frontmatter."""
|
|
i, m = self._setup(tmp_path)
|
|
i.upsert_context_section(tmp_path)
|
|
i.upsert_context_section(tmp_path)
|
|
content = (tmp_path / i.context_file).read_text(encoding="utf-8")
|
|
assert content.count("alwaysApply") == 1
|
|
|
|
def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path):
|
|
"""Removing the section from a Speckit-only .mdc deletes the file."""
|
|
i, m = self._setup(tmp_path)
|
|
i.upsert_context_section(tmp_path)
|
|
ctx_path = tmp_path / i.context_file
|
|
assert ctx_path.exists()
|
|
i.remove_context_section(tmp_path)
|
|
assert not ctx_path.exists()
|
|
|
|
|
|
class TestCursorAgentAutoPromote:
|
|
"""--ai cursor-agent auto-promotes to integration path."""
|
|
|
|
def test_ai_cursor_agent_without_ai_skills_auto_promotes(self, tmp_path):
|
|
"""--ai cursor-agent should work the same as --integration cursor-agent."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
target = tmp_path / "test-proj"
|
|
result = runner.invoke(app, ["init", str(target), "--ai", "cursor-agent", "--no-git", "--ignore-agent-tools", "--script", "sh"])
|
|
|
|
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_false_for_ide_first_flow(self):
|
|
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
|
|
|
|
``specify init --ai cursor-agent`` (without ``--ignore-agent-tools``)
|
|
treats ``requires_cli=True`` as a hard precheck and fails when the
|
|
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
|
|
/ skills flow can run without it. Workflow dispatch support is
|
|
signalled by overriding ``build_exec_args()`` instead, mirroring
|
|
``CopilotIntegration``.
|
|
"""
|
|
i = get_integration("cursor-agent")
|
|
assert i.config.get("requires_cli") is False
|
|
|
|
def test_install_url_is_set(self):
|
|
i = get_integration("cursor-agent")
|
|
url = i.config.get("install_url")
|
|
assert url is not None
|
|
# CodeQL: use a hostname comparison instead of a substring check
|
|
# to avoid the "Incomplete URL substring sanitization" warning
|
|
# (substring "cursor.com" can also appear in attacker-controlled
|
|
# positions of an arbitrary URL).
|
|
host = (urlparse(url).hostname or "").lower()
|
|
assert host == "cursor.com" or host.endswith(".cursor.com")
|
|
|
|
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", "--approve-mcps", "--force",
|
|
"/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", "--approve-mcps", "--force",
|
|
"/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", "--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_exec_args_supports_dispatch_without_requires_cli(self):
|
|
"""``build_exec_args`` must return argv even though ``requires_cli``
|
|
is ``False``.
|
|
|
|
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
|
|
precheck (so ``specify init`` doesn't fail when the CLI isn't on
|
|
PATH) but still supports workflow dispatch. The presence of a
|
|
non-``None`` argv from ``build_exec_args()`` is what the engine
|
|
keys off — pin that invariant.
|
|
"""
|
|
i = get_integration("cursor-agent")
|
|
assert i.config.get("requires_cli") is False
|
|
argv = i.build_exec_args("/speckit-plan", output_json=False)
|
|
assert argv is not None
|
|
assert argv[0] == "cursor-agent"
|
|
|
|
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"
|
|
|
|
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: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
|
|
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"
|
|
|