From 253b2be010ff12e8b3231151fdfca01b37e9ab1e Mon Sep 17 00:00:00 2001 From: Ketlark Date: Thu, 3 Sep 2026 09:55:22 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(llm):=20cursor-cli=20backend=20?= =?UTF-8?q?=E2=80=94=20semantic=20extraction=20through=20the=20locally=20a?= =?UTF-8?q?uthenticated=20Cursor=20Agent=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the claude-cli backend: zero pricing (subscription usage is not metered API spend), forced-serial calls unless GRAPHIFY_CURSOR_CLI_PARALLEL=1, and a credential gate that accepts the cursor-agent CLI in place of an API key. The prompt travels over stdin (real extraction chunks exceed argv size limits), ask mode keeps the agent read-only over the corpus it reads, and --trust satisfies non-interactive workspace trust. Envelope errors surface before hollow-retry bisection, matching the #2554 handling in claude-cli. GRAPHIFY_CURSOR_CLI_MODEL pins a model; the default stays Cursor's own auto routing. Complements #3073 (openai-cli) with the Cursor equivalent. --- CHANGELOG.md | 4 + graphify/__main__.py | 4 +- graphify/cli.py | 10 ++ graphify/llm.py | 155 +++++++++++++++++++++- tests/test_cursor_cli_backend.py | 221 +++++++++++++++++++++++++++++++ 5 files changed, 389 insertions(+), 5 deletions(-) create mode 100644 tests/test_cursor_cli_backend.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d818561b..ba2baf326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## Unreleased + +- Feature: a new `cursor-cli` backend (`--backend cursor-cli`) runs semantic extraction and community labeling through the locally authenticated Cursor Agent CLI (`cursor-agent -p`), so the work rides a Cursor subscription instead of a metered API key. It mirrors the `claude-cli` backend: zero pricing, extraction instructions delivered in the user turn, serial calls unless `GRAPHIFY_CURSOR_CLI_PARALLEL=1`, and `GRAPHIFY_CURSOR_CLI_MODEL` to pin a model (default: Cursor's own `auto` routing). Ask mode keeps the agent read-only over the corpus, `--trust` satisfies non-interactive workspace trust, and the prompt travels over stdin because real extraction chunks exceed argv size limits. Token usage comes from the JSON envelope's `usage` block. + ## 0.9.53 (2026-08-30) - Fix: a batch of cross-language inheritance-edge corrections (thanks @Synvoya): JavaScript `class X extends Y` now emits an `inherits` edge (#1790); PHP interfaces, enums, and traits are captured as class-like nodes with their heritage (#1791); Scala `trait` declarations become class-like nodes (#1792) and qualified `extends`/`with` bases resolve to the tail type (#1794); a qualified Kotlin supertype resolves to its tail type instead of the package head (#1793); a C# interface extending an interface is classified as `inherits`, not `implements` (#1817); and a Go interface type-set constraint no longer emits a spurious `embeds` edge (#1818). diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240..cb0f16902 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -571,13 +571,13 @@ def _run_cli() -> None: print(" --no-label keep 'Community N' placeholders (skip LLM community naming)") print(" --backend= backend to use for community naming (default: auto-detect)") print(" --model= model to use for community naming") - print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") + print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli/cursor-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" label (re)name communities with the configured LLM backend, regenerate report") print(" --missing-only keep existing labels and only name missing/placeholder communities") print(" --backend= backend to use (default: auto-detect from API keys)") print(" --model= model to use for community naming") - print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)") + print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli/cursor-cli)") print(" --batch-size=N communities per labeling LLM call (default 100)") print(" query \"\" BFS traversal of graph.json for a question") print(" --dfs use depth-first instead of breadth-first") diff --git a/graphify/cli.py b/graphify/cli.py index 6642f57d5..1ec5a80f9 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -3686,6 +3686,16 @@ def _parse_float(name: str, raw: str) -> float: file=sys.stderr, ) sys.exit(1) + elif backend == "cursor-cli": + import shutil as _shutil + allow_no_key = _shutil.which("cursor-agent") is not None + if not allow_no_key: + print( + "error: backend 'cursor-cli' requires the `cursor-agent` CLI on $PATH " + "(install Cursor Agent and run `cursor-agent login` to authenticate).", + file=sys.stderr, + ) + sys.exit(1) if not allow_no_key: print( f"error: backend '{backend}' requires {_format_backend_env_keys(backend)} to be set.", diff --git a/graphify/llm.py b/graphify/llm.py index 75f637881..c9fc08b05 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -219,6 +219,16 @@ def _resolve_ollama_base_url(default: str) -> str: # CLI's Read tool rather than as inline base64 (see `_call_claude_cli`). "vision": True, }, + "cursor-cli": { + # Routes through the locally-installed Cursor Agent CLI + # (`cursor-agent -p`), authenticated via the user's Cursor + # subscription (`cursor-agent login`) instead of a separate API key — + # costs are billed to the plan, not pay-as-you-go API credit. + "default_model": "cursor-agent-plan", + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + }, } @@ -1782,6 +1792,93 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo return result +def _cli_extraction_prompt(user_message: str, *, deep: bool) -> str: + """Build the single user-turn message for CLI backends (cursor-cli). + + Mirrors the claude-cli delivery: the extraction schema plus an explicit + imperative go in the USER turn rather than a system-prompt flag, because + local coding-agent context (AGENTS.md, rules, MCP) otherwise dilutes the + instructions and the CLI replies conversationally, which parses hollow. + """ + return ( + _extraction_system(deep=deep) + + "\n\n---\n" + + "Now extract the knowledge graph from the following source file(s) " + + "and output ONLY the JSON object described above. No prose, no " + + "preamble, no markdown fences.\n\n" + + user_message + ) + + +def _call_cursor_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: + """Call Cursor via the locally-installed Cursor Agent CLI (``cursor-agent -p``). + + Authenticates via the user's Cursor subscription (``cursor-agent login``) + — costs are billed to the plan. The reply arrives as a JSON envelope + (``--output-format json``) whose ``result`` field carries the model text + and ``usage`` the token counts. Ask mode keeps the agent read-only; + ``--trust`` is required for non-interactive runs. The prompt goes over + stdin because real extraction chunks exceed argv size limits. Images are + not attached natively (no flag), so cursor-cli is not a vision backend. + """ + import shutil + import subprocess + + cursor_cmd = shutil.which("cursor-agent") + if cursor_cmd is None: + raise RuntimeError( + "Cursor Agent CLI not found on $PATH. Install it and run " + "`cursor-agent login` to authenticate with your Cursor subscription." + ) + combined_message = _cli_extraction_prompt(user_message, deep=deep_mode) + cli_args = [ + cursor_cmd, "-p", + "--trust", + "--output-format", "json", + "--mode", "ask", + ] + cli_model = os.environ.get("GRAPHIFY_CURSOR_CLI_MODEL", "").strip() + if cli_model: + cli_args.extend(["--model", cli_model]) + proc = subprocess.run( + cli_args, + input=combined_message, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_resolve_api_timeout(), + check=False, + **_no_window_kwargs(), + ) + # The CLI reports API failures in the stdout JSON envelope, not stderr, so + # a nonzero exit with a parseable envelope surfaces that cause (#2554). + # A non-JSON stdout on a success exit must fail loudly rather than parse + # to an empty graph the hollow-retry path would bisect forever. + try: + envelope = json.loads((proc.stdout or "").strip()) + except ValueError: + envelope = None + if proc.returncode != 0 or (envelope or {}).get("is_error"): + detail = ( + (proc.stderr or "").strip() + or (str(envelope.get("result") or "") if envelope else "")[:500] + or "(no stderr, no error envelope)" + ) + raise RuntimeError(f"cursor-agent -p failed: {detail[:500]}") + if envelope is None: + raise RuntimeError("cursor-agent -p produced an unparseable JSON envelope") + raw_content = str(envelope.get("result", "") or "") + result = _parse_llm_json(raw_content or "{}") + usage = envelope.get("usage") or {} + result["input_tokens"] = int(usage.get("inputTokens", 0) or 0) + result["output_tokens"] = int(usage.get("outputTokens", 0) or 0) + result["model"] = cli_model or "cursor-agent-plan" + result["finish_reason"] = "stop" + _mark_hollow(result, raw_content, "cursor-cli") + return result + + def _azure_client(api_key: str, endpoint: str): """Construct an AzureOpenAI client with env-driven api_version and timeout.""" try: @@ -1939,7 +2036,7 @@ def extract_files_direct( file=sys.stderr, ) key = "ollama" - if not key and backend not in ("bedrock", "claude-cli"): + if not key and backend not in ("bedrock", "claude-cli", "cursor-cli"): raise ValueError( f"No API key for backend '{backend}'. " f"Set {_format_backend_env_keys(backend)} or pass api_key=." @@ -1963,6 +2060,8 @@ def extract_files_direct( result = _call_claude(key, mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "claude-cli": result = _call_claude_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) + elif backend == "cursor-cli": + result = _call_cursor_cli(user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "bedrock": result = _call_bedrock(mdl, user_msg, max_tokens=max_out, deep_mode=deep_mode, images=image_refs) elif backend == "azure": @@ -2621,6 +2720,8 @@ def _run_one(idx: int, chunk: list[Path]) -> tuple[int, dict | None, Exception | # over session state. Force serial unless the user explicitly opts in. if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1 + if backend == "cursor-cli" and os.environ.get("GRAPHIFY_CURSOR_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 def _checkpoint_chunk(result: dict, chunk: "list[Path | FileSlice]") -> None: # Persist each chunk's semantic results to the cache as soon as it # completes. Without this, the semantic cache is only written once, at @@ -2863,7 +2964,7 @@ def _call_llm( ollama_url = _resolve_ollama_base_url(cfg.get("base_url", "")) _validate_ollama_base_url(ollama_url) key = "ollama" - if not key and backend not in ("bedrock", "claude-cli"): + if not key and backend not in ("bedrock", "claude-cli", "cursor-cli"): raise ValueError( f"No API key for backend '{backend}'. Set {_format_backend_env_keys(backend)}." ) @@ -2936,6 +3037,52 @@ def _rec(inp, out) -> None: cli_usage.get("output_tokens", 0), ) return envelope.get("result", "") + if backend == "cursor-cli": + import shutil, subprocess + cursor_cmd = shutil.which("cursor-agent") + if cursor_cmd is None: + raise RuntimeError("Cursor Agent CLI not found on $PATH") + cli_args = [ + cursor_cmd, "-p", + "--trust", + "--output-format", "json", + "--mode", "ask", + ] + if model is not None: + cli_args.extend(["--model", mdl]) + proc = subprocess.run( + cli_args, + input=prompt, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=_resolve_api_timeout(), + check=False, + **_no_window_kwargs(), + ) + # Nonzero exit with a parseable envelope surfaces the envelope's + # error text; garbage stdout on success fails loudly (#2554). + try: + envelope = json.loads((proc.stdout or "").strip()) + except ValueError: + envelope = None + if proc.returncode != 0 or (envelope or {}).get("is_error"): + detail = ( + (proc.stderr or "").strip() + or (str(envelope.get("result") or "") if envelope else "")[:500] + or "(no stderr, no error envelope)" + ) + raise RuntimeError(f"cursor-agent -p failed: {detail[:500]}") + if envelope is None: + raise RuntimeError( + "cursor-agent -p produced an unparseable JSON envelope" + ) + cli_usage = envelope.get("usage") or {} + if cli_usage: + _rec(cli_usage.get("inputTokens", 0) or 0, cli_usage.get("outputTokens", 0) or 0) + return str(envelope.get("result", "") or "") + if backend == "bedrock": @@ -3135,7 +3282,7 @@ def detect_backend() -> str | None: _validate_ollama_base_url(ollama_url) return "ollama" for name in BACKENDS: - if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli"): + if name not in ("gemini", "kimi", "claude", "openai", "deepseek", "azure", "bedrock", "ollama", "claude-cli", "cursor-cli"): if _get_backend_api_key(name): return name return None @@ -3353,6 +3500,8 @@ def label_communities( max_concurrency = 1 if backend == "claude-cli" and os.environ.get("GRAPHIFY_CLAUDE_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1 + if backend == "cursor-cli" and os.environ.get("GRAPHIFY_CURSOR_CLI_PARALLEL", "").strip() != "1": + max_concurrency = 1 workers = max(1, min(max_concurrency, n_batches)) def _run_batch(batch_idx: int): diff --git a/tests/test_cursor_cli_backend.py b/tests/test_cursor_cli_backend.py new file mode 100644 index 000000000..6afd86160 --- /dev/null +++ b/tests/test_cursor_cli_backend.py @@ -0,0 +1,221 @@ +"""Tests for the `cursor-cli` backend. + +Mirrors tests/test_claude_cli_backend.py: mocks subprocess.run + +shutil.which so the suite runs on CI without the `cursor-agent` +binary or a live network call. +""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from graphify import llm + +_ENVELOPE = { + "type": "result", + "subtype": "success", + "is_error": False, + "result": json.dumps({ + "nodes": [ + {"id": "foo_module", "label": "Foo", "file_type": "document", "source_file": "foo.md"}, + {"id": "foo_greet", "label": "greet", "file_type": "code", "source_file": "foo.md"}, + ], + "edges": [ + {"source": "foo_module", "target": "foo_greet", + "relation": "references", "confidence": "EXTRACTED", "confidence_score": 1.0}, + ], + "hyperedges": [], + "input_tokens": 0, + "output_tokens": 0, + }), + "session_id": "c71b4c4b-0000-0000-0000-000000000000", + "usage": {"inputTokens": 15946, "outputTokens": 31, "cacheReadTokens": 5376}, +} + +_ERROR_ENVELOPE = { + "type": "result", + "subtype": "error", + "is_error": True, + "result": "API Error: Rate limit reached", + "usage": {"inputTokens": 0, "outputTokens": 0}, +} + + +@pytest.fixture +def fake_cursor(monkeypatch): + completed = MagicMock(returncode=0, stdout=json.dumps(_ENVELOPE), stderr="") + monkeypatch.setattr(llm, "_response_is_hollow", lambda raw, parsed: False) + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed) as run: + yield run + + +def test_returns_parsed_nodes_and_edges(fake_cursor): + result = llm._call_cursor_cli("dummy", max_tokens=8192) + assert len(result["nodes"]) == 2 + assert len(result["edges"]) == 1 + + +def test_token_accounting_uses_envelope_usage(fake_cursor): + # cursor-agent reports camelCase token counts in the JSON envelope. + result = llm._call_cursor_cli("dummy", max_tokens=8192) + assert result["input_tokens"] == 15946 + assert result["output_tokens"] == 31 + assert result["finish_reason"] == "stop" + + +def test_raises_when_cli_missing(): + with patch("shutil.which", return_value=None): + with pytest.raises(RuntimeError, match="Cursor Agent CLI not found"): + llm._call_cursor_cli("dummy", max_tokens=8192) + + +def test_raises_on_nonzero_exit(): + completed = MagicMock(returncode=2, stdout="", stderr="auth failed") + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="cursor-agent -p failed"): + llm._call_cursor_cli("dummy", max_tokens=8192) + + +def test_raises_on_error_envelope_with_zero_exit(): + # cursor-agent flags API failures with is_error in the stdout envelope + # while exiting 0. Parsing `result` as model output would yield an empty + # graph that the hollow-retry path then bisects forever (#2554). + completed = MagicMock( + returncode=0, stdout=json.dumps(_ERROR_ENVELOPE), stderr="", + ) + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="Rate limit reached"): + llm._call_cursor_cli("dummy", max_tokens=8192) + + +def test_raises_on_error_envelope_when_stderr_carries_the_cause(): + completed = MagicMock( + returncode=1, stdout=json.dumps(_ERROR_ENVELOPE), stderr="", + ) + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="Rate limit reached"): + llm._call_cursor_cli("dummy", max_tokens=8192) + + +def test_raises_on_garbage_envelope(): + # A non-JSON stdout with exit 0 must fail loudly rather than parse to an + # empty graph (#2554). + completed = MagicMock(returncode=0, stdout="not json", stderr="") + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="unparseable JSON envelope"): + llm._call_cursor_cli("dummy", max_tokens=8192) + + +def test_call_llm_raises_on_error_envelope(): + # _call_llm feeds community labeling and the dedup tiebreaker; an error + # envelope must not leak its prose into the graph as a label (#2554). + completed = MagicMock( + returncode=0, stdout=json.dumps(_ERROR_ENVELOPE), stderr="", + ) + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed): + with pytest.raises(RuntimeError, match="Rate limit reached"): + llm._call_llm("dummy", backend="cursor-cli") + + +def test_call_llm_success_still_returns_result_text(): + envelope = dict(_ENVELOPE, result="a fine label") + completed = MagicMock(returncode=0, stdout=json.dumps(envelope), stderr="") + with patch("shutil.which", return_value="/fake/bin/cursor-agent"), \ + patch("subprocess.run", return_value=completed): + assert llm._call_llm("dummy", backend="cursor-cli") == "a fine label" + + +def test_call_llm_accumulates_usage(fake_cursor): + usage_out: dict = {} + llm._call_llm("dummy", backend="cursor-cli", usage_out=usage_out) + assert usage_out["input"] == 15946 + assert usage_out["output"] == 31 + + +def test_extract_files_direct_dispatches_to_cursor_cli(tmp_path, fake_cursor): + f = tmp_path / "foo.md" + f.write_text("# Foo\n\nThe greet() helper formats a name.\n") + result = llm.extract_files_direct(files=[f], backend="cursor-cli", root=tmp_path) + assert fake_cursor.called + assert len(result["nodes"]) == 2 + + +def test_backend_registered_with_zero_cost(): + assert "cursor-cli" in llm.BACKENDS + pricing = llm.BACKENDS["cursor-cli"]["pricing"] + assert pricing["input"] == 0.0 + assert pricing["output"] == 0.0 + assert llm.estimate_cost("cursor-cli", 1_000_000, 1_000_000) == 0.0 + + +# ---------- invocation shape ---------- + + +def test_trust_and_ask_flags_in_subprocess(fake_cursor): + # --trust satisfies non-interactive workspace trust; ask mode keeps the + # agent read-only over the corpus it is extracting from. + llm._call_cursor_cli("dummy", max_tokens=8192) + argv = fake_cursor.call_args.args[0] + assert "--trust" in argv + assert "--mode" in argv + assert "ask" in argv + assert "--output-format" in argv + assert "json" in argv + + +def test_prompt_travels_over_stdin(fake_cursor): + # Real extraction chunks exceed argv size limits (Linux MAX_ARG_STRLEN + # is 128 KB; chunks reach 240-306 KB), so the prompt must ride stdin. + llm._call_cursor_cli("UNIQUE_SOURCE_MARKER", max_tokens=8192) + argv = fake_cursor.call_args.args[0] + assert "UNIQUE_SOURCE_MARKER" not in " ".join(argv) + sent = fake_cursor.call_args.kwargs["input"] + assert "UNIQUE_SOURCE_MARKER" in sent + + +def test_no_model_flag_by_default(fake_cursor): + # Without GRAPHIFY_CURSOR_CLI_MODEL the CLI's own model routing (auto) + # decides; graphify must not pin one. + llm._call_cursor_cli("dummy", max_tokens=8192) + argv = fake_cursor.call_args.args[0] + assert "--model" not in argv + + +def test_model_env_var_pins_model(fake_cursor, monkeypatch): + monkeypatch.setenv("GRAPHIFY_CURSOR_CLI_MODEL", "composer-2.5") + llm._call_cursor_cli("dummy", max_tokens=8192) + argv = fake_cursor.call_args.args[0] + assert "--model" in argv + assert "composer-2.5" in argv + + +# ---------- extraction instructions delivered in the user turn ---------- +# Same failure mode as claude-cli (#2076/#2554): a bare file dump with no +# explicit request makes a coding agent reply conversationally, which parses +# to zero nodes. The instructions ride in the user turn instead. + + +def test_extraction_instructions_ride_in_user_turn(fake_cursor): + """The full extraction schema, an explicit imperative, and the source must + all be delivered via stdin.""" + llm._call_cursor_cli("UNIQUE_SOURCE_MARKER", max_tokens=8192) + sent = fake_cursor.call_args.kwargs["input"] + assert "graphify semantic extraction agent" in sent + assert "output ONLY the JSON object" in sent + assert "UNIQUE_SOURCE_MARKER" in sent + + +def test_user_turn_preserves_untrusted_source_guardrails(fake_cursor): + """The guardrails from _extraction_system must survive + the move into the user turn (prompt-injection defence is unchanged).""" + llm._call_cursor_cli("dummy", max_tokens=8192) + sent = fake_cursor.call_args.kwargs["input"] + assert "untrusted_source" in sent From 1693e18408d1a69e86d8f31af1371eb3ed948aa9 Mon Sep 17 00:00:00 2001 From: Ketlark Date: Thu, 3 Sep 2026 10:09:08 +0200 Subject: [PATCH 2/2] test(cursor-cli): scrub GRAPHIFY_CURSOR_CLI_MODEL in the default-routing test The grounded gate flagged that test_no_model_flag_by_default depends on the ambient environment: a developer with GRAPHIFY_CURSOR_CLI_MODEL exported would fail the no-model assertion. Mirror the existing GRAPHIFY_API_TIMEOUT delenv precedent from tests/test_claude_cli_backend.py. --- tests/test_cursor_cli_backend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_cursor_cli_backend.py b/tests/test_cursor_cli_backend.py index 6afd86160..9a7f580e0 100644 --- a/tests/test_cursor_cli_backend.py +++ b/tests/test_cursor_cli_backend.py @@ -181,9 +181,11 @@ def test_prompt_travels_over_stdin(fake_cursor): assert "UNIQUE_SOURCE_MARKER" in sent -def test_no_model_flag_by_default(fake_cursor): +def test_no_model_flag_by_default(fake_cursor, monkeypatch): # Without GRAPHIFY_CURSOR_CLI_MODEL the CLI's own model routing (auto) - # decides; graphify must not pin one. + # decides; graphify must not pin one. Scrub the env so a developer's + # exported var cannot leak into the assertion. + monkeypatch.delenv("GRAPHIFY_CURSOR_CLI_MODEL", raising=False) llm._call_cursor_cli("dummy", max_tokens=8192) argv = fake_cursor.call_args.args[0] assert "--model" not in argv