Repository navigation
Add ug cursor app to open the Cursor desktop app
#1163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6de29be
99f44f4
ccff8e2
5d2b861
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 <agent>` 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] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| **_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 ".") | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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": | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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") | ||||||||||||
|
Comment on lines
+150
to
+152
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion:
Suggested change
|
||||||||||||
|
|
||||||||||||
| 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" | ||||||||||||
|
|
||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is the
(".", "..")check needed?Path(".").exists()andPath("..").exists()are always true, so that branch never adds anything.