Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions src/ucode/agents/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
is the (".", "..") check needed? Path(".").exists() and Path("..").exists() are always true, so that branch never adds anything.

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]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
what happens with ug cursor app new.py on macOS? open -a exits non-zero when a file doesn't exist, but cursor new.py creates it. Can we prefer the cursor CLI whenever it's on PATH and use open only as a fallback?

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
what happens if cursor exits right away with an error (bad flag, broken install)? stderr goes to DEVNULL and the cli still prints "Opened the Cursor app". can we briefly poll for an early non-zero exit like we do for open?

**_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 ".")
)
19 changes: 17 additions & 2 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
ug cursor app ... used to pass app to cursor-agent as its prompt. Now it opens the desktop app instead, which breaks that call. Could we use a separate cursor-app command or a flag, so that existing forwarded args keep working?

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 "
Expand All @@ -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:
Expand Down
140 changes: 140 additions & 0 deletions tests/test_agent_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
from __future__ import annotations

import json
import subprocess
from unittest.mock import MagicMock

import pytest

from ucode.agents import cursor

Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
This test fails on a mac with /Applications/Cursor.app installed, because the macOS branch returns open -a ... first. Can we patch sys.platform (or CURSOR_MACOS_APP_PATHS) like the other tests do?

Suggestion:

Suggested change
def test_uses_cursor_command_on_path(self, monkeypatch):
monkeypatch.setattr(cursor.shutil, "which", lambda name: "/usr/bin/cursor")
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"
Expand Down
41 changes: 41 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
Loading