[cursor] Clarify CLI setup readiness (#2733)

* 🐛 fix(cursor): Clarify CLI setup readiness

- Keep Cursor CLI and SDK configuration under one setup entry
- Prioritize cursor-agent install/login readiness over API-key state
- Surface actionable install and login guidance in the web picker

* 📸 docs(cursor): Add setup guidance demo

* 🎨 style(web): Apply locked Prettier formatting

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

* 🧪 test(web): Cover Cursor setup guidance end to end

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>

---------

Signed-off-by: sabhya-db <sabhya.chhabria@databricks.com>
Co-authored-by: sabhya-db <sabhya.chhabria@databricks.com>
This commit is contained in:
Sabhya Chhabria
2026-07-16 17:39:19 -07:00
committed by GitHub
parent 4c5161364a
commit 6364d0bc1e
6 changed files with 310 additions and 51 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+108 -17
View File
@@ -10232,8 +10232,8 @@ def _prompt_install_cursor() -> str | None:
return None
def _manage_cursor_harness() -> None:
"""Run the level-2 loop for Cursor: manage its ``CURSOR_API_KEY``.
def _manage_cursor_sdk_harness() -> None:
"""Run the Cursor SDK loop: manage its ``CURSOR_API_KEY``.
Cursor runs via the ``cursor-sdk`` package and authenticates against
Cursor's own backend with a ``CURSOR_API_KEY`` — the SDK requires one (a
@@ -10350,6 +10350,84 @@ def _set_cursor_api_key() -> str | None:
return "✓ Cursor API key stored"
def _manage_cursor_native_harness() -> None:
"""Configure the ``cursor-agent`` CLI used by the built-in web agent."""
from omnigent.onboarding.harness_install import (
CURSOR_KEY,
harness_cli_installed,
harness_cli_logged_in,
harness_install_spec,
harness_login,
harness_logout,
)
from omnigent.onboarding.interactive import console, select
if not harness_cli_installed(CURSOR_KEY):
spec = harness_install_spec(CURSOR_KEY)
hint = (
spec.install_hint
if spec and spec.install_hint
else "curl https://cursor.com/install -fsS | bash"
)
console.print(
" Cursor CLI isn't installed. Install it with:\n"
f" [bold]{hint}[/bold]\n"
" then run [bold]cursor-agent login[/bold] or re-open this menu."
)
return
status: str | None = None
while True:
logged_in = harness_cli_logged_in(CURSOR_KEY)
header = "Cursor CLI — logged in" if logged_in else "Cursor CLI — not logged in yet"
rows = [_HarnessMenuRow("Sign in (cursor-agent login)", action="login")]
if logged_in:
rows.append(_HarnessMenuRow("Sign out (cursor-agent logout)", action="logout"))
rows.append(_HarnessMenuRow("← Back", action="back"))
idx = select(header, [row.label for row in rows], clear_on_exit=True, status=status)
if idx < 0 or rows[idx].action == "back":
return
if rows[idx].action == "login":
status = (
"✓ Cursor CLI logged in" if harness_login(CURSOR_KEY) else "Login not detected"
)
elif rows[idx].action == "logout":
status = "✓ Cursor CLI logged out" if harness_logout(CURSOR_KEY) else "Logout failed"
def _manage_cursor_harness() -> None:
"""Configure Cursor CLI and SDK from one consolidated setup entry."""
from omnigent.onboarding.cursor_auth import cursor_api_key_configured
from omnigent.onboarding.harness_install import (
CURSOR_KEY,
harness_cli_installed,
harness_cli_logged_in,
)
from omnigent.onboarding.interactive import select
while True:
cli_status = (
"logged in"
if harness_cli_logged_in(CURSOR_KEY)
else "needs login"
if harness_cli_installed(CURSOR_KEY)
else "not installed"
)
sdk_status = "API key configured" if cursor_api_key_configured() else "not configured"
rows = [
_HarnessMenuRow(f"Cursor CLI — {cli_status}", action="cli"),
_HarnessMenuRow(f"Cursor SDK — {sdk_status}", action="sdk"),
_HarnessMenuRow("← Back", action="back"),
]
idx = select("Cursor setup", [row.label for row in rows], clear_on_exit=True)
if idx < 0 or rows[idx].action == "back":
return
if rows[idx].action == "cli":
_manage_cursor_native_harness()
elif rows[idx].action == "sdk":
_manage_cursor_sdk_harness()
def _prompt_install_antigravity() -> str | None:
"""Offer to install the missing ``antigravity`` extra; return a status line.
@@ -11825,11 +11903,7 @@ def _run_configure_harnesses_interactive() -> None:
copilot_github_token_configured,
copilot_sdk_installed,
)
from omnigent.onboarding.cursor_auth import (
CURSOR_EXTRA,
cursor_api_key_configured,
cursor_sdk_installed,
)
from omnigent.onboarding.cursor_auth import cursor_api_key_configured
from omnigent.onboarding.extra_install import extra_install_display
from omnigent.onboarding.goose_auth import goose_config_summary
from omnigent.onboarding.harness_install import (
@@ -11842,6 +11916,7 @@ def _run_configure_harnesses_interactive() -> None:
OPENCODE_KEY,
QWEN_KEY,
harness_cli_installed,
harness_cli_logged_in,
harness_install_command,
harness_install_spec,
)
@@ -11976,29 +12051,45 @@ def _run_configure_harnesses_interactive() -> None:
rows.append(_family_row(ANTHROPIC_FAMILY))
rows.append(_family_row(OPENAI_FAMILY))
# Cursor — readiness is the CURSOR_API_KEY (the cursor-sdk extra is a
# soft dependency; the key is independently storable, so a missing SDK
# is surfaced as the install hint, not a hard block).
if cursor_api_key_configured(config) or bool(os.environ.get("CURSOR_API_KEY")):
rows.append((CURSOR_KEY, "Cursor", "API key", "ready", ""))
elif not cursor_sdk_installed():
# Cursor setup covers both surfaces, but readiness prioritizes the CLI
# used by the built-in web agent. An SDK key never hides a CLI problem.
cursor_sdk_ready = cursor_api_key_configured(config) or bool(
os.environ.get("CURSOR_API_KEY")
)
if not harness_cli_installed(CURSOR_KEY):
cursor_spec = harness_install_spec(CURSOR_KEY)
cursor_hint = (
cursor_spec.install_hint
if cursor_spec and cursor_spec.install_hint
else "curl https://cursor.com/install -fsS | bash"
)
rows.append(
(
CURSOR_KEY,
"Cursor",
"Not installed",
"CLI not installed · SDK ready" if cursor_sdk_ready else "CLI not installed",
"missing",
_install_hint(extra_install_display(CURSOR_EXTRA)),
_install_hint(cursor_hint),
),
)
elif harness_cli_logged_in(CURSOR_KEY):
rows.append(
(
CURSOR_KEY,
"Cursor",
"CLI + SDK ready" if cursor_sdk_ready else "CLI ready",
"ready",
"",
)
)
else:
rows.append(
(
CURSOR_KEY,
"Cursor",
"Not configured",
"CLI needs login · SDK ready" if cursor_sdk_ready else "CLI needs login",
"warn",
"Open to add the Cursor API key.",
"Open to run `cursor-agent login`.",
),
)
+69 -33
View File
@@ -129,6 +129,10 @@ def _harnesses_installed(monkeypatch):
"omnigent.onboarding.harness_install.harness_logout",
lambda family: True,
)
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_logged_in",
lambda family: True,
)
def _config_yaml(config_home) -> dict[str, object]:
@@ -1757,6 +1761,43 @@ def test_overview_lists_kiro_row(isolated_config, monkeypatch) -> None:
assert "kiro-cli login" in Text.from_markup(descriptions[kiro]).plain
def test_overview_reports_missing_cursor_cli_despite_sdk_api_key(
isolated_config, monkeypatch
) -> None:
"""A Cursor SDK key must not make the native Cursor CLI look ready."""
from rich.text import Text
monkeypatch.setenv("CURSOR_API_KEY", "crsr_sdk_only")
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda key: key != "cursor",
)
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
assert "Cursor CLI" not in names
assert "Cursor SDK" not in names
cursor = names.index("Cursor")
assert "CLI not installed" in Text.from_markup(options[cursor]).plain
assert "SDK ready" in Text.from_markup(options[cursor]).plain
assert "cursor.com/install" in Text.from_markup(descriptions[cursor]).plain
def test_missing_cursor_cli_drillin_shows_install_and_login(isolated_config, monkeypatch) -> None:
"""The consolidated Cursor setup gives both steps needed by the web agent."""
monkeypatch.setattr(
"omnigent.onboarding.harness_install.harness_cli_installed",
lambda key: key != "cursor",
)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input="3\n1\nq\nq\n")
assert result.exit_code == 0, result.output
assert "curl https://cursor.com/install -fsS | bash" in result.output
assert "cursor-agent login" in result.output
def test_overview_hermes_row_reflects_configured_model(isolated_config, monkeypatch) -> None:
"""Hermes reads ready (with its picked model) once ``hermes model`` has run.
@@ -1986,7 +2027,7 @@ def test_overview_descriptions_map_to_their_rows(isolated_config, monkeypatch) -
}
assert desc_by_name["Claude"] == "Open to add a credential."
assert desc_by_name["Codex"] == "Open to add a credential."
assert desc_by_name["Cursor"] == "Open to add the Cursor API key."
assert desc_by_name["Cursor"] == ""
assert desc_by_name["OpenCode"] == "Open to sign in (opencode auth login)."
assert desc_by_name["Hermes"] == "Open to configure with `hermes model`."
assert desc_by_name["Pi"] == "Open to add a credential."
@@ -2422,7 +2463,8 @@ def test_add_menu_readds_dismissed_cli_config_credential(isolated_config) -> Non
# ── Cursor API-key flow ─────────────────────────────────────────────────────
# Cursor runs via the ``cursor-sdk`` package and authenticates with a
# ``CURSOR_API_KEY``; it has no provider/gateway family. Its drill-in (L1 row 4)
# ``CURSOR_API_KEY``; it has no provider/gateway family. Its drill-in is under
# the consolidated Cursor row (L1 row 3, then Cursor SDK).
# stores the key in the secret store + a dedicated ``cursor:`` config block,
# mirroring the other harnesses' api-key persistence. The menu is API-key-only
# (Set/Replace/Remove), so it touches neither the ``cursor-agent`` binary nor a
@@ -2456,9 +2498,8 @@ def test_cursor_set_api_key_paste_writes_block_and_secret(
Proves the api-key path: the secret lands in the store (never plaintext in
config) and the config references it via ``keychain:cursor``.
"""
# L1 3=Cursor → cursor menu 1=Set API key → paste key (crsr_ → no warn) →
# cursor menu q=back → L1 q=quit.
stdin = "\n".join(["3", "1", "crsr_test_key_123", "q", "q"]) + "\n"
# L1 Cursor → Cursor SDK → Set API key → paste key → back through both menus.
stdin = "\n".join(["3", "2", "1", "crsr_test_key_123", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2478,9 +2519,8 @@ def test_cursor_adopt_env_api_key_writes_env_ref(
at the live environment variable so the key never leaves the user's shell.
"""
monkeypatch.setenv("CURSOR_API_KEY", "crsr_env_key_456")
# L1 3=Cursor → 1=Set API key → "y" adopt detected $CURSOR_API_KEY →
# q back → q quit.
stdin = "\n".join(["3", "1", "y", "q", "q"]) + "\n"
# L1 Cursor → Cursor SDK → Set API key → adopt detected env key → back.
stdin = "\n".join(["3", "2", "1", "y", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2499,9 +2539,8 @@ def test_cursor_remove_api_key_drops_block_and_secret(
with open(config_path, "w") as f:
yaml.safe_dump({"cursor": {"api_key_ref": "keychain:cursor"}}, f)
# L1 3=Cursor → cursor menu (key set: 1=Replace 2=Remove 3=Back) → 2=Remove
# → q back → q quit.
stdin = "\n".join(["3", "2", "q", "q"]) + "\n"
# L1 Cursor → Cursor SDK → Remove → back through both menus.
stdin = "\n".join(["3", "2", "2", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2518,9 +2557,8 @@ def test_cursor_set_api_key_non_crsr_declined_is_not_stored(
The soft prefix check warns and asks to store anyway; declining must leave
both the secret store and the config untouched.
"""
# L1 3=Cursor → 1=Set API key → paste non-crsr_ key → "n" decline warning →
# q back → q quit.
stdin = "\n".join(["3", "1", "sk-not-a-cursor-key", "n", "q", "q"]) + "\n"
# L1 Cursor → Cursor SDK → Set → decline the non-crsr_ warning → back.
stdin = "\n".join(["3", "2", "1", "sk-not-a-cursor-key", "n", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2550,23 +2588,18 @@ def _cursor_sdk_absent(monkeypatch):
)
def test_cursor_overview_install_command_is_selection_only(
def test_cursor_overview_stays_cli_ready_when_sdk_missing(
isolated_config, _cursor_sdk_absent, monkeypatch
) -> None:
"""With the cursor extra absent, the Cursor row's install command is its description.
The install command (dynamically computed) is the selection-only hint —
the selector's per-row description, shown when the row is highlighted —
and is NOT baked into the always-visible row label.
"""
"""A missing SDK does not duplicate or downgrade a ready Cursor CLI row."""
from rich.text import Text
options, selectable, descriptions, _, _max_visible = _capture_setup_overview(monkeypatch)
names = _overview_row_names(options, selectable)
assert names.count("Cursor") == 1
cursor = names.index("Cursor")
assert "omnigent[cursor]" in Text.from_markup(descriptions[cursor]).plain
# The command lives in the description only — never the always-visible row.
assert "omnigent[cursor]" not in Text.from_markup(options[cursor]).plain
assert "CLI ready" in Text.from_markup(options[cursor]).plain
assert Text.from_markup(descriptions[cursor]).plain == ""
def test_cursor_drillin_offers_install_when_sdk_missing(
@@ -2577,8 +2610,8 @@ def test_cursor_drillin_offers_install_when_sdk_missing(
Here the user picks "show the command" (choice 3), which prints it and falls
through to the key menu, then backs out.
"""
# L1 3=Cursor → install offer 3=show command → key menu q=back → L1 q.
stdin = "\n".join(["3", "3", "q", "q"]) + "\n"
# Cursor → Cursor SDK → show command → back through both menus.
stdin = "\n".join(["3", "2", "3", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
out = result.output
@@ -2593,9 +2626,8 @@ def test_cursor_key_settable_when_sdk_missing(isolated_config, _cursor_sdk_absen
NOT gate key management on it. Here the user declines ("set the key anyway" =
choice 2), then sets the key — which must persist as it does with the SDK.
"""
# L1 3=Cursor → install offer 2=set key anyway → key menu 1=Set →
# paste crsr_ key → key menu q=back → L1 q=quit.
stdin = "\n".join(["3", "2", "1", "crsr_key_no_sdk", "q", "q"]) + "\n"
# Cursor → Cursor SDK → set key anyway → Set → paste key → back.
stdin = "\n".join(["3", "2", "2", "1", "crsr_key_no_sdk", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2624,8 +2656,8 @@ def test_cursor_install_now_invokes_runner_without_index(
monkeypatch.setattr("omnigent.onboarding.extra_install.shutil.which", lambda name: None)
monkeypatch.setattr("omnigent.onboarding.cursor_auth.subprocess.run", _run)
# L1 3=Cursor → install offer 1=install now → key menu q=back → L1 q.
stdin = "\n".join(["3", "1", "q", "q"]) + "\n"
# Cursor → Cursor SDK → install now → back through both menus.
stdin = "\n".join(["3", "2", "1", "q", "q", "q"]) + "\n"
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=stdin)
assert result.exit_code == 0, result.output
@@ -2851,7 +2883,11 @@ def test_copilot_overview_install_command_is_selection_only(
@pytest.mark.parametrize(
"choice,sdk_probe,unexpected_header",
[
("3", "omnigent.onboarding.cursor_auth.cursor_sdk_installed", "Cursor — no API key yet"),
(
"3\n2",
"omnigent.onboarding.cursor_auth.cursor_sdk_installed",
"Cursor — no API key yet",
),
(
"7",
"omnigent.onboarding.antigravity_auth.antigravity_sdk_installed",
@@ -2876,7 +2912,7 @@ def test_soft_sdk_install_prompt_abort_returns_to_overview(
"""
monkeypatch.setattr(sdk_probe, lambda: False)
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=f"{choice}\nq\nq\n")
result = CliRunner().invoke(cli, ["setup", "--no-internal-beta"], input=f"{choice}\nq\nq\nq\n")
assert result.exit_code == 0, result.output
assert unexpected_header not in result.output
@@ -0,0 +1,101 @@
"""E2E: Cursor setup guidance in the New Chat agent picker."""
from __future__ import annotations
import json
import re
from playwright.sync_api import Page, Route, expect
_HOST_ID = "host_cursor_e2e"
_HOST_NAME = "cursor-e2e-host"
_AGENT_ID = "ag_cursor_e2e"
def _fulfill_hosts(route: Route) -> None:
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"hosts": [
{
"host_id": _HOST_ID,
"name": _HOST_NAME,
"owner": "e2e",
"status": "online",
"configured_harnesses": {"cursor-native": False},
}
]
}
),
)
def _fulfill_agents(route: Route) -> None:
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"data": [
{
"id": _AGENT_ID,
"name": "cursor-native-ui",
"display_name": "Cursor",
"description": "Cursor's coding agent",
"harness": "cursor-native",
"skills": [],
}
]
}
),
)
def _fulfill_empty_agent_scan(route: Route) -> None:
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps({"data": []}),
)
def test_cursor_missing_cli_shows_install_and_login_guidance(
page: Page,
seeded_session: tuple[str, str],
) -> None:
"""A missing Cursor CLI is badged and explained before session launch."""
base_url, session_id = seeded_session
del session_id
page.route("**/v1/hosts", _fulfill_hosts)
page.route("**/v1/agents", _fulfill_agents)
page.route(re.compile(r"/v1/sessions\?.*kind=any"), _fulfill_empty_agent_scan)
page.add_init_script(
f"""window.localStorage.setItem(
"omnigent:recent-workspaces",
JSON.stringify({{ {_HOST_ID!r}: ["/work/repo"] }})
);"""
)
page.goto(f"{base_url}/")
composer = page.get_by_test_id("new-chat-landing-input")
expect(composer).to_be_visible(timeout=30_000)
warning = page.get_by_test_id("new-chat-landing-harness-warning")
expect(warning).to_be_visible(timeout=30_000)
expect(warning).to_contain_text(f"Cursor needs cursor-agent on {_HOST_NAME}")
expect(warning).to_contain_text("curl https://cursor.com/install -fsS | bash")
expect(warning).to_contain_text("cursor-agent login")
expect(warning.locator("code")).to_have_count(2)
# The guidance is visible before launch and remains warning-only.
composer.fill("help me inspect this repository")
expect(page.get_by_test_id("new-chat-landing-submit")).to_be_enabled()
expect(warning).to_be_visible()
page.get_by_test_id("new-chat-landing-agent-select").click()
badge = page.get_by_test_id(f"new-chat-landing-agent-warning-{_AGENT_ID}")
expect(badge).to_be_visible()
expect(badge).to_have_text("install & login")
+10
View File
@@ -459,6 +459,16 @@ describe("harnessUnconfiguredOnHost", () => {
);
});
it("shows native Cursor's install and login steps before launch", () => {
const testHost = hostWith({ "cursor-native": false });
const reason = harnessUnavailableReasonOnHost("cursor-native", testHost);
expect(reason).toBe("cursor-cli-missing");
expect(harnessWarningBadgeText(reason)).toBe("install & login");
expect(harnessWarningMessageText("Cursor", "laptop", reason)).toBe(
"Cursor needs cursor-agent on laptop — install it with `curl https://cursor.com/install -fsS | bash`, then run `cursor-agent login`.",
);
});
it("surfaces structured codex unavailable reasons", () => {
const testHost = hostWith({ codex: "needs-auth", "codex-native": "binary-missing" });
expect(harnessUnconfiguredOnHost("codex", testHost)).toBe(true);
+22 -1
View File
@@ -544,13 +544,21 @@ function isCodexHarness(harness: string): boolean {
return harness === "codex" || harness === "codex-native" || harness === "native-codex";
}
function isNativeCursorHarness(harness: string): boolean {
return harness === "cursor-native" || harness === "native-cursor";
}
export function harnessUnavailableReasonOnHost(
harness: string | null | undefined,
host: Host | undefined | null,
): string | null {
if (!harness || !host?.configured_harnesses) return null;
const availability = host.configured_harnesses[harness];
if (availability === false) return isCodexHarness(harness) ? "binary-missing" : "unconfigured";
if (availability === false) {
if (isCodexHarness(harness)) return "binary-missing";
if (isNativeCursorHarness(harness)) return "cursor-cli-missing";
return "unconfigured";
}
if (
isCodexHarness(harness) &&
(availability === "binary-missing" || availability === "needs-auth")
@@ -564,6 +572,7 @@ export function harnessUnavailableReasonOnHost(
export function harnessWarningBadgeText(reason: string | null): string {
if (reason === "binary-missing") return "binary missing";
if (reason === "needs-auth") return "needs auth";
if (reason === "cursor-cli-missing") return "install & login";
return "needs setup";
}
@@ -572,6 +581,9 @@ export function harnessWarningMessageText(
hostName: string | undefined,
reason: string | null,
): string {
if (reason === "cursor-cli-missing") {
return `${agentName} needs cursor-agent on ${hostName} — install it with \`curl https://cursor.com/install -fsS | bash\`, then run \`cursor-agent login\`.`;
}
if (reason === "needs-auth") {
return `${agentName} needs Codex authentication on ${hostName} — run codex login on that machine.`;
}
@@ -586,6 +598,15 @@ function harnessWarningMessage(
hostName: string | undefined,
reason: string | null,
): ReactNode {
if (reason === "cursor-cli-missing") {
return (
<>
{agentName} needs cursor-agent on {hostName} install it with{" "}
<code>curl https://cursor.com/install -fsS | bash</code>, then run{" "}
<code>cursor-agent login</code>.
</>
);
}
if (reason === "needs-auth") {
return (
<>