From 6de29be14be611b8443d778968d2559d4e1063be Mon Sep 17 00:00:00 2001 From: Achille Negrier Date: Fri, 16 Oct 2026 14:07:54 +0000 Subject: [PATCH 1/4] feat: add `ug cursor app` to open the Cursor desktop app `ug cursor app [args]` opens the Cursor desktop app, which reads the same ~/.cursor/mcp.json that `ug mcp add` writes. It uses the `cursor` shell command when on PATH, falls back to `open -a Cursor.app` on macOS, and prints an install hint when the app is not found. Co-authored-by: Isaac --- README.md | 1 + src/ucode/agents/cursor.py | 31 +++++++++++++++++++++++++++++++ src/ucode/cli.py | 17 ++++++++++++++++- tests/test_agent_cursor.py | 31 +++++++++++++++++++++++++++++++ tests/test_cli.py | 26 ++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 1 deletion(-) 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..16b5dfc75 100644 --- a/src/ucode/agents/cursor.py +++ b/src/ucode/agents/cursor.py @@ -17,12 +17,19 @@ from __future__ import annotations +import shutil +import sys from pathlib import Path from ucode.config_io import apply_json_mcp_diff, read_json_safe, write_json_file from ucode.launcher import exec_or_spawn 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 +110,27 @@ 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]) + + +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. + + Prefers the `cursor` shell command (installed by Cursor on every platform), and + falls back to `open -a` on the macOS app bundle when that command isn't on PATH.""" + binary = shutil.which(CURSOR_APP_BINARY) + if binary: + return [binary, *tool_args] + if sys.platform == "darwin": + for bundle in CURSOR_MACOS_APP_PATHS: + if bundle.exists(): + return ["open", "-a", str(bundle), *tool_args] + return None + + +def launch_app(argv: list[str]) -> None: + """Open the Cursor desktop app. + + 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.""" + exec_or_spawn(argv) diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 48daa32aa..ba9dd1c69 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`." + ) + print_success("Opening the Cursor app") + cursor.launch_app(argv) + return if not shutil.which(cursor.CURSOR_BINARY): raise RuntimeError( f"`{cursor.CURSOR_BINARY}` was not found on PATH. Install Cursor Agent " diff --git a/tests/test_agent_cursor.py b/tests/test_agent_cursor.py index c4ba796ac..460d3707a 100644 --- a/tests/test_agent_cursor.py +++ b/tests/test_agent_cursor.py @@ -142,6 +142,37 @@ 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.shutil, "which", lambda name: "/usr/bin/cursor") + + assert cursor.find_app_launch_argv(["."]) == ["/usr/bin/cursor", "."] + + def test_macos_falls_back_to_open_app_bundle(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + monkeypatch.setattr(cursor.shutil, "which", lambda name: None) + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (tmp_path / "missing.app", bundle)) + + assert cursor.find_app_launch_argv(["."]) == ["open", "-a", str(bundle), "."] + + 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_execs_argv(self, monkeypatch): + execs: list[list[str]] = [] + monkeypatch.setattr(cursor, "exec_or_spawn", lambda argv: execs.append(argv)) + + cursor.launch_app(["cursor", "."]) + + assert execs == [["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..3844dad39 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3586,6 +3586,32 @@ 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( "names,expected", [ From 99f44f4e93246b8f0d2007a5c24d4c8bf02da491 Mon Sep 17 00:00:00 2001 From: Achille Negrier Date: Sat, 17 Oct 2026 12:51:58 +0000 Subject: [PATCH 2/4] Return right after opening the Cursor app Co-authored-by: Isaac --- src/ucode/agents/cursor.py | 33 ++++++++++++++++++++++++-------- tests/test_agent_cursor.py | 39 ++++++++++++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/ucode/agents/cursor.py b/src/ucode/agents/cursor.py index 16b5dfc75..f5ac19044 100644 --- a/src/ucode/agents/cursor.py +++ b/src/ucode/agents/cursor.py @@ -18,11 +18,20 @@ from __future__ import annotations 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" @@ -115,22 +124,30 @@ def launch(state: dict, tool_args: list[str]) -> None: 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. - Prefers the `cursor` shell command (installed by Cursor on every platform), and - falls back to `open -a` on the macOS app bundle when that command isn't on PATH.""" - binary = shutil.which(CURSOR_APP_BINARY) - if binary: - return [binary, *tool_args] + On macOS prefers `open -a` on the app bundle, which returns as soon as the app is + launched. Otherwise uses the `cursor` shell command (installed by Cursor on every + platform); `launch_app` starts it detached so it can't hold the terminal.""" if sys.platform == "darwin": for bundle in CURSOR_MACOS_APP_PATHS: if bundle.exists(): return ["open", "-a", str(bundle), *tool_args] + binary = shutil.which(CURSOR_APP_BINARY) + if binary: + return [binary, *tool_args] return None def launch_app(argv: list[str]) -> None: - """Open the Cursor desktop app. + """Open the Cursor desktop app and return without waiting for it. 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.""" - exec_or_spawn(argv) + needed here. The process is detached (own session, no stdio) because the `cursor` + shell command can stay attached for as long as the app runs.""" + subprocess_cross_os.popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + **_DETACHED_POPEN_OPTIONS, + ) diff --git a/tests/test_agent_cursor.py b/tests/test_agent_cursor.py index 460d3707a..6a04b8033 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 @@ -148,15 +152,22 @@ def test_uses_cursor_command_on_path(self, monkeypatch): assert cursor.find_app_launch_argv(["."]) == ["/usr/bin/cursor", "."] - def test_macos_falls_back_to_open_app_bundle(self, tmp_path, monkeypatch): + 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: None) + 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)) assert cursor.find_app_launch_argv(["."]) == ["open", "-a", str(bundle), "."] + 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") @@ -164,13 +175,29 @@ def test_not_installed_returns_none(self, tmp_path, monkeypatch): assert cursor.find_app_launch_argv([]) is None - def test_launch_app_execs_argv(self, monkeypatch): - execs: list[list[str]] = [] - monkeypatch.setattr(cursor, "exec_or_spawn", lambda argv: execs.append(argv)) + 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], + ) + monkeypatch.setattr( + cursor, "exec_or_spawn", lambda argv: pytest.fail("must not wait on the app") + ) cursor.launch_app(["cursor", "."]) - assert execs == [["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 + proc.wait.assert_not_called() class TestWriteUserMcpServers: From ccff8e285ae11c94b060881a3b044c28cc823590 Mon Sep 17 00:00:00 2001 From: Achille Negrier Date: Sat, 17 Oct 2026 13:27:04 +0000 Subject: [PATCH 3/4] Address review: forward app args safely on macOS, platform-independent tests Co-authored-by: Isaac --- src/ucode/agents/cursor.py | 10 +++++++-- tests/test_agent_cursor.py | 46 ++++++++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/ucode/agents/cursor.py b/src/ucode/agents/cursor.py index f5ac19044..dec443145 100644 --- a/src/ucode/agents/cursor.py +++ b/src/ucode/agents/cursor.py @@ -125,11 +125,17 @@ 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. On macOS prefers `open -a` on the app bundle, which returns as soon as the app is - launched. Otherwise uses the `cursor` shell command (installed by Cursor on every - platform); `launch_app` starts it detached so it can't hold the terminal.""" + launched. `open` parses dashed options itself, so args are forwarded two ways: + plain paths only (`ug cursor app .`) go straight after the bundle, so `open` hands + them to Cursor as files/folders to open; if any arg starts with `-`, all args go + after `--args` (in their original order, so flag values stay attached) and reach + Cursor verbatim. Otherwise uses the `cursor` shell command (installed by Cursor on + every platform); `launch_app` starts it detached so it can't hold the terminal.""" if sys.platform == "darwin": for bundle in CURSOR_MACOS_APP_PATHS: if bundle.exists(): + if any(arg.startswith("-") for arg in tool_args): + return ["open", "-a", str(bundle), "--args", *tool_args] return ["open", "-a", str(bundle), *tool_args] binary = shutil.which(CURSOR_APP_BINARY) if binary: diff --git a/tests/test_agent_cursor.py b/tests/test_agent_cursor.py index 6a04b8033..369a18ea3 100644 --- a/tests/test_agent_cursor.py +++ b/tests/test_agent_cursor.py @@ -6,8 +6,6 @@ import subprocess from unittest.mock import MagicMock -import pytest - from ucode.agents import cursor WS = "https://example.databricks.com" @@ -148,6 +146,7 @@ def test_execs_cursor_agent_without_token_wiring(self, monkeypatch): 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", "."] @@ -161,6 +160,44 @@ def test_macos_prefers_open_app_bundle_over_cursor_command(self, tmp_path, monke assert cursor.find_app_launch_argv(["."]) == ["open", "-a", str(bundle), "."] + def test_macos_forwards_flags_after_args_separator(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (bundle,)) + + assert cursor.find_app_launch_argv(["--new-window"]) == [ + "open", + "-a", + str(bundle), + "--args", + "--new-window", + ] + + def test_macos_flags_and_paths_keep_order_after_args_separator(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + 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", + "a.py:3", + ".", + ] + + def test_macos_paths_only_go_straight_to_open(self, tmp_path, monkeypatch): + bundle = tmp_path / "Cursor.app" + bundle.mkdir() + monkeypatch.setattr(cursor.sys, "platform", "darwin") + monkeypatch.setattr(cursor, "CURSOR_MACOS_APP_PATHS", (bundle,)) + + assert cursor.find_app_launch_argv(["a", "b"]) == ["open", "-a", str(bundle), "a", "b"] + 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") @@ -183,10 +220,6 @@ def test_launch_app_starts_detached_without_waiting(self, monkeypatch): "popen", lambda argv, **kw: (calls.append((argv, kw)), proc)[1], ) - monkeypatch.setattr( - cursor, "exec_or_spawn", lambda argv: pytest.fail("must not wait on the app") - ) - cursor.launch_app(["cursor", "."]) assert len(calls) == 1 @@ -197,7 +230,6 @@ def test_launch_app_starts_detached_without_waiting(self, monkeypatch): assert kwargs["stderr"] is subprocess.DEVNULL for key, value in cursor._DETACHED_POPEN_OPTIONS.items(): assert kwargs[key] == value - proc.wait.assert_not_called() class TestWriteUserMcpServers: From 5d2b861e268ace2f59f9cbf7b31d7704017c2b00 Mon Sep 17 00:00:00 2001 From: Achille Negrier Date: Sat, 17 Oct 2026 14:10:21 +0000 Subject: [PATCH 4/4] Address review round 3: resolve app paths and flags, report launch failures Co-authored-by: Isaac --- src/ucode/agents/cursor.py | 76 ++++++++++++++++++++++++++++++-------- src/ucode/cli.py | 4 +- tests/test_agent_cursor.py | 74 +++++++++++++++++++++++++++++++------ tests/test_cli.py | 15 ++++++++ 4 files changed, 139 insertions(+), 30 deletions(-) diff --git a/src/ucode/agents/cursor.py b/src/ucode/agents/cursor.py index dec443145..41fde0e8b 100644 --- a/src/ucode/agents/cursor.py +++ b/src/ucode/agents/cursor.py @@ -17,6 +17,8 @@ from __future__ import annotations +import os +import re import shutil import subprocess import sys @@ -121,39 +123,81 @@ def launch(state: dict, tool_args: list[str]) -> None: 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. - On macOS prefers `open -a` on the app bundle, which returns as soon as the app is - launched. `open` parses dashed options itself, so args are forwarded two ways: - plain paths only (`ug cursor app .`) go straight after the bundle, so `open` hands - them to Cursor as files/folders to open; if any arg starts with `-`, all args go - after `--args` (in their original order, so flag values stay attached) and reach - Cursor verbatim. Otherwise uses the `cursor` shell command (installed by Cursor on - every platform); `launch_app` starts it detached so it can't hold the terminal.""" + 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(): - if any(arg.startswith("-") for arg in tool_args): - return ["open", "-a", str(bundle), "--args", *tool_args] - return ["open", "-a", str(bundle), *tool_args] - binary = shutil.which(CURSOR_APP_BINARY) + 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 waiting for it. + """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 stdio) because the `cursor` - shell command can stay attached for as long as the app runs.""" - subprocess_cross_os.popen( + 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.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 ba9dd1c69..6fba92a6a 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -3884,8 +3884,8 @@ def cursor_cmd(ctx: typer.Context) -> None: "Cursor runs models on your Cursor account; its Databricks MCP servers " "in ~/.cursor/mcp.json authenticate through `ug mcp-proxy`." ) - print_success("Opening the Cursor app") cursor.launch_app(argv) + print_success("Opened the Cursor app") return if not shutil.which(cursor.CURSOR_BINARY): raise RuntimeError( @@ -3899,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 369a18ea3..c8a4915e0 100644 --- a/tests/test_agent_cursor.py +++ b/tests/test_agent_cursor.py @@ -6,6 +6,8 @@ import subprocess from unittest.mock import MagicMock +import pytest + from ucode.agents import cursor WS = "https://example.databricks.com" @@ -157,26 +159,29 @@ def test_macos_prefers_open_app_bundle_over_cursor_command(self, tmp_path, monke 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), "."] + assert cursor.find_app_launch_argv(["."]) == ["open", "-a", str(bundle), str(tmp_path)] - def test_macos_forwards_flags_after_args_separator(self, tmp_path, monkeypatch): + 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"]) == [ - "open", - "-a", - str(bundle), - "--args", + assert cursor.find_app_launch_argv(["--new-window", "."]) == [ + "/usr/bin/cursor", "--new-window", + ".", ] - def test_macos_flags_and_paths_keep_order_after_args_separator(self, tmp_path, monkeypatch): + 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,)) @@ -186,17 +191,25 @@ def test_macos_flags_and_paths_keep_order_after_args_separator(self, tmp_path, m str(bundle), "--args", "--goto", - "a.py:3", - ".", + str(tmp_path / "a.py") + ":3", + str(tmp_path), ] - def test_macos_paths_only_go_straight_to_open(self, tmp_path, monkeypatch): + 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", "b"]) == ["open", "-a", str(bundle), "a", "b"] + 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") @@ -231,6 +244,43 @@ def test_launch_app_starts_detached_without_waiting(self, monkeypatch): 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): diff --git a/tests/test_cli.py b/tests/test_cli.py index 3844dad39..3432bc0c7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3612,6 +3612,21 @@ def test_cursor_app_reports_when_not_installed(): 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", [