diff --git a/README.md b/README.md index 79efd7401..b1987608c 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ ug opencode ug copilot ug pi ug cursor # Cursor Agent, MCP-only +ug cursor app # Cursor desktop app, MCP-only ``` On first launch of a model-backed agent, `ug` prompts for a Databricks diff --git a/src/ucode/agents/cursor.py b/src/ucode/agents/cursor.py index a5afc2c3b..41fde0e8b 100644 --- a/src/ucode/agents/cursor.py +++ b/src/ucode/agents/cursor.py @@ -17,12 +17,30 @@ from __future__ import annotations +import os +import re +import shutil +import subprocess +import sys from pathlib import Path +from typing import Any from ucode.config_io import apply_json_mcp_diff, read_json_safe, write_json_file from ucode.launcher import exec_or_spawn +from ucode.os_compatibility import subprocess_cross_os + +_DETACHED_POPEN_OPTIONS: dict[str, Any] = ( + {"creationflags": subprocess.CREATE_NO_WINDOW} + if sys.platform == "win32" + else {"start_new_session": True} +) CURSOR_BINARY = "cursor-agent" +CURSOR_APP_BINARY = "cursor" +CURSOR_MACOS_APP_PATHS = ( + Path("/Applications/Cursor.app"), + Path.home() / "Applications" / "Cursor.app", +) CURSOR_CONFIG_DIR = Path.home() / ".cursor" CURSOR_MCP_CONFIG_PATH = CURSOR_CONFIG_DIR / "mcp.json" @@ -103,3 +121,83 @@ def launch(state: dict, tool_args: list[str]) -> None: convenience wrapper over `cursor-agent` (kept for symmetry with the other `ucode ` launchers).""" exec_or_spawn([CURSOR_BINARY, *tool_args]) + + +_LINE_COL_SUFFIX = re.compile(r"(:\d+){1,2}$") +_OPEN_WAIT_SECONDS = 10 + + +def _absolutize_paths(tool_args: list[str]) -> list[str]: + """Resolve path-like args (existing paths, `.`/`..`, and `file:line[:col]`) against + the cwd. `open` starts the app with `/` as its cwd, so relative paths would break.""" + resolved = [] + for arg in tool_args: + if arg.startswith("-"): + resolved.append(arg) + continue + bare = _LINE_COL_SUFFIX.sub("", arg) + if arg in (".", "..") or Path(arg).exists() or Path(bare).exists(): + resolved.append(os.path.abspath(arg)) + else: + resolved.append(arg) + return resolved + + +def find_app_launch_argv(tool_args: list[str]) -> list[str] | None: + """Return the argv that opens the Cursor desktop app, or None if it isn't installed. + + When any flag is passed (`--new-window`, `--goto`, ...) the `cursor` shell command + is preferred on every platform: it is the editor's own CLI, handles flags and + relative paths, and reuses a running instance. Without it on macOS, flags go after + `open --args` with path-like args made absolute; macOS ignores `--args` when Cursor + is already running, so flags may have no effect then. No flags on macOS: `open -a` + on the bundle with absolute paths, which returns as soon as the app is launched. + Otherwise the `cursor` command is used; `launch_app` starts it detached so it can't + hold the terminal.""" + has_flags = any(arg.startswith("-") for arg in tool_args) + binary = shutil.which(CURSOR_APP_BINARY) + if has_flags and binary: + return [binary, *tool_args] + if sys.platform == "darwin": + for bundle in CURSOR_MACOS_APP_PATHS: + if bundle.exists(): + args = _absolutize_paths(tool_args) + if has_flags: + return ["open", "-a", str(bundle), "--args", *args] + return ["open", "-a", str(bundle), *args] + if binary: + return [binary, *tool_args] + return None + + +def launch_app(argv: list[str]) -> None: + """Open the Cursor desktop app and return without hanging the terminal. + + The desktop app reads the same ~/.cursor/mcp.json as `cursor-agent`, and the + Databricks MCP servers in it run `ug mcp-proxy`, so no env or token wiring is + needed here. The process is detached (own session, no stdin/stdout) because the + `cursor` shell command can stay attached for as long as the app runs. + + `open` returns quickly, so it is waited on briefly and a non-zero exit raises + RuntimeError with its stderr. The `cursor` command is not waited on; failing to + start it raises OSError.""" + is_open = Path(argv[0]).name == "open" + proc = subprocess_cross_os.popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE if is_open else subprocess.DEVNULL, + **_DETACHED_POPEN_OPTIONS, + ) + if not is_open: + return + try: + _, stderr = proc.communicate(timeout=_OPEN_WAIT_SECONDS) + except subprocess.TimeoutExpired: + return + if proc.returncode: + detail = (stderr or b"").decode(errors="replace").strip() + raise RuntimeError( + f"Failed to open the Cursor app (`open` exited {proc.returncode})" + + (f": {detail}" if detail else ".") + ) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 48daa32aa..6fba92a6a 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -3861,7 +3861,7 @@ def pi_cmd( rich_help_panel="Launch", ) def cursor_cmd(ctx: typer.Context) -> None: - """Launch Cursor Agent. + """Launch Cursor Agent (`ug cursor app` opens the Cursor desktop app). Cursor is MCP-only: `cursor-agent` runs models on your own Cursor account, so ug configures no models for it. Its Databricks MCP servers (added via @@ -3872,6 +3872,21 @@ def cursor_cmd(ctx: typer.Context) -> None: from ucode.agents import cursor try: + if ctx.args and ctx.args[0] == "app": + argv = cursor.find_app_launch_argv(ctx.args[1:]) + if argv is None: + raise RuntimeError( + "The Cursor desktop app was not found. Install it " + "(https://cursor.com/download), then re-run `ug cursor app`." + ) + print_section("Unity Gateway with Cursor") + print_note( + "Cursor runs models on your Cursor account; its Databricks MCP servers " + "in ~/.cursor/mcp.json authenticate through `ug mcp-proxy`." + ) + cursor.launch_app(argv) + print_success("Opened the Cursor app") + return if not shutil.which(cursor.CURSOR_BINARY): raise RuntimeError( f"`{cursor.CURSOR_BINARY}` was not found on PATH. Install Cursor Agent " @@ -3884,7 +3899,7 @@ def cursor_cmd(ctx: typer.Context) -> None: ) print_success("Starting Cursor Agent") cursor.launch(load_state(), ctx.args) - except RuntimeError as exc: + except (RuntimeError, OSError) as exc: print_err(str(exc)) raise typer.Exit(1) from None except KeyboardInterrupt: diff --git a/tests/test_agent_cursor.py b/tests/test_agent_cursor.py index c4ba796ac..c8a4915e0 100644 --- a/tests/test_agent_cursor.py +++ b/tests/test_agent_cursor.py @@ -3,6 +3,10 @@ from __future__ import annotations import json +import subprocess +from unittest.mock import MagicMock + +import pytest from ucode.agents import cursor @@ -142,6 +146,142 @@ def test_execs_cursor_agent_without_token_wiring(self, monkeypatch): assert execs == [["cursor-agent", "--resume"]] +class TestLaunchApp: + def test_uses_cursor_command_on_path(self, monkeypatch): + monkeypatch.setattr(cursor.sys, "platform", "linux") + monkeypatch.setattr(cursor.shutil, "which", lambda name: "/usr/bin/cursor") + + assert cursor.find_app_launch_argv(["."]) == ["/usr/bin/cursor", "."] + + def test_macos_prefers_open_app_bundle_over_cursor_command(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + monkeypatch.setattr(cursor.shutil, "which", lambda name: "/usr/bin/cursor") + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (tmp_path / "missing.app", bundle)) + monkeypatch.chdir(tmp_path) + + assert cursor.find_app_launch_argv(["."]) == ["open", "-a", str(bundle), str(tmp_path)] + + def test_flags_prefer_cursor_cli_even_on_macos(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + monkeypatch.setattr(cursor.shutil, "which", lambda name: "/usr/bin/cursor") + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (bundle,)) + + assert cursor.find_app_launch_argv(["--new-window", "."]) == [ + "/usr/bin/cursor", + "--new-window", + ".", + ] + + def test_macos_flags_without_cli_use_args_with_absolute_paths(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + (tmp_path / "a.py").write_text("") + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cursor.shutil, "which", lambda name: None) + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (bundle,)) + + assert cursor.find_app_launch_argv(["--goto", "a.py:3", "."]) == [ + "open", + "-a", + str(bundle), + "--args", + "--goto", + str(tmp_path / "a.py") + ":3", + str(tmp_path), + ] + + def test_macos_paths_only_are_made_absolute(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + (tmp_path / "a").mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (bundle,)) + + assert cursor.find_app_launch_argv(["a", "nonexistent"]) == [ + "open", + "-a", + str(bundle), + str(tmp_path / "a"), + "nonexistent", + ] + + def test_macos_without_bundle_uses_cursor_command(self, tmp_path, monkeypatch): + monkeypatch.setattr(cursor.shutil, "which", lambda name: "/usr/bin/cursor") + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (tmp_path / "missing.app",)) + + assert cursor.find_app_launch_argv(["."]) == ["/usr/bin/cursor", "."] + + def test_not_installed_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setattr(cursor.shutil, "which", lambda name: None) + monkeypatch.setattr(cursor.sys, "platform", "linux") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (tmp_path / "missing.app",)) + + assert cursor.find_app_launch_argv([]) is None + + def test_launch_app_starts_detached_without_waiting(self, monkeypatch): + calls: list[tuple[list[str], dict]] = [] + proc = MagicMock() + monkeypatch.setattr( + cursor.subprocess_cross_os, + "popen", + lambda argv, **kw: (calls.append((argv, kw)), proc)[1], + ) + cursor.launch_app(["cursor", "."]) + + assert len(calls) == 1 + argv, kwargs = calls[0] + assert argv == ["cursor", "."] + assert kwargs["stdin"] is subprocess.DEVNULL + assert kwargs["stdout"] is subprocess.DEVNULL + assert kwargs["stderr"] is subprocess.DEVNULL + for key, value in cursor._DETACHED_POPEN_OPTIONS.items(): + assert kwargs[key] == value + + def test_launch_app_open_nonzero_exit_raises_with_stderr(self, monkeypatch): + proc = MagicMock(returncode=1) + proc.communicate.return_value = (None, b"Unable to find application") + monkeypatch.setattr(cursor.subprocess_cross_os, "popen", lambda argv, **kw: proc) + + with pytest.raises(RuntimeError, match="exited 1.*Unable to find application"): + cursor.launch_app(["open", "-a", "/x/Cursor.app"]) + + def test_launch_app_open_success_and_timeout_do_not_raise(self, monkeypatch): + ok = MagicMock(returncode=0) + ok.communicate.return_value = (None, b"") + slow = MagicMock(returncode=None) + slow.communicate.side_effect = subprocess.TimeoutExpired("open", 10) + procs = iter([ok, slow]) + monkeypatch.setattr(cursor.subprocess_cross_os, "popen", lambda argv, **kw: next(procs)) + + cursor.launch_app(["open", "-a", "/x/Cursor.app"]) + cursor.launch_app(["open", "-a", "/x/Cursor.app"]) + + def test_launch_app_cursor_cli_is_not_waited_on(self, monkeypatch): + proc = MagicMock() + monkeypatch.setattr(cursor.subprocess_cross_os, "popen", lambda argv, **kw: proc) + + cursor.launch_app(["/usr/bin/cursor", "."]) + + proc.communicate.assert_not_called() + proc.wait.assert_not_called() + + def test_launch_app_propagates_oserror(self, monkeypatch): + def boom(argv, **kw): + raise OSError("exec format error") + + monkeypatch.setattr(cursor.subprocess_cross_os, "popen", boom) + + with pytest.raises(OSError): + cursor.launch_app(["/usr/bin/cursor", "."]) + + class TestWriteUserMcpServers: def test_batched_add_remove_preserves_user_entries(self, tmp_path, monkeypatch): path = tmp_path / "mcp.json" diff --git a/tests/test_cli.py b/tests/test_cli.py index 64a2a9649..3432bc0c7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3586,6 +3586,47 @@ def test_cursor_launch_uses_unity_gateway_branding(): assert "Unity Gateway with Cursor" in result.output +def test_cursor_app_opens_desktop_app(): + with ( + patch("ucode.agents.cursor.find_app_launch_argv", return_value=["cursor", "."]) as find, + patch("ucode.agents.cursor.launch_app") as launch_app, + patch("ucode.agents.cursor.launch") as launch, + ): + result = runner.invoke(app, ["cursor", "app", "."]) + + assert result.exit_code == 0, result.output + find.assert_called_once_with(["."]) + launch_app.assert_called_once_with(["cursor", "."]) + launch.assert_not_called() + + +def test_cursor_app_reports_when_not_installed(): + with ( + patch("ucode.agents.cursor.find_app_launch_argv", return_value=None), + patch("ucode.agents.cursor.launch_app") as launch_app, + ): + result = runner.invoke(app, ["cursor", "app"]) + + assert result.exit_code == 1 + assert "Cursor desktop app was not found" in " ".join(result.output.split()) + launch_app.assert_not_called() + + +@pytest.mark.parametrize("exc", [RuntimeError("open exited 1"), OSError("exec failed")]) +def test_cursor_app_launch_failure_reports_error_without_success(exc): + with ( + patch("ucode.agents.cursor.find_app_launch_argv", return_value=["cursor", "."]), + patch("ucode.agents.cursor.launch_app", side_effect=exc), + ): + result = runner.invoke(app, ["cursor", "app", "."]) + + assert result.exit_code == 1 + out = " ".join(result.output.split()) + assert str(exc) in out + assert "Opened the Cursor app" not in out + assert "Traceback" not in out + + @pytest.mark.parametrize( "names,expected", [