Skip to content

feat(llm): cursor-cli backend — semantic extraction through the locally authenticated Cursor Agent CLI - #3311

Open
Ketlark wants to merge 2 commits into
Graphify-Labs:v8from
Ketlark:cursor-cli-backend
Open

feat(llm): cursor-cli backend — semantic extraction through the locally authenticated Cursor Agent CLI#3311
Ketlark wants to merge 2 commits into
Graphify-Labs:v8from
Ketlark:cursor-cli-backend

Conversation

@Ketlark

@Ketlark Ketlark commented Sep 3, 2026

Copy link
Copy Markdown

Complements #3073 (openai-cli, Codex side) with the Cursor equivalent. Same shape as the existing claude-cli backend: zero pricing (subscription usage is not metered API spend), forced-serial calls unless opted out, and a credential gate that accepts a present CLI in place of an API key.

What it adds

--backend cursor-cli runs semantic extraction and community labeling through cursor-agent -p, the locally authenticated Cursor Agent CLI, so the work rides a Cursor subscription instead of a metered API key.

Four things worth stating for review:

  1. The prompt goes over stdin, not argv. Real extraction chunks reach 240–306 KB; Linux caps a single argv entry at 128 KB (MAX_ARG_STRLEN), so argv delivery fails with E2BIG on ordinary input.
  2. --mode ask keeps the agent read-only. An agentic CLI given write access to the corpus it is reading can modify what it is extracting from. --trust satisfies non-interactive workspace trust (headless runs abort with a trust prompt otherwise).
  3. Envelope errors surface before hollow-retry bisection. cursor-agent flags API failures with is_error in the stdout envelope while exiting 0. Parsing result as model output yields an empty graph that the bisect path then subdivides forever; a nonzero exit with a parseable envelope surfaces the envelope's cause, and garbage stdout on a success exit fails loudly. This mirrors the claude-cli: API errors in the stdout envelope go unreported, so a rate limit prints a blank exited 1:, keeps retrying against the outage, and can land in graph.json as a community label #2554 handling in claude-cli.
  4. Model routing is not pinned. Without GRAPHIFY_CURSOR_CLI_MODEL no --model flag is passed and Cursor's own auto routing decides; the env var pins a specific model when wanted.

Token usage is read from the JSON envelope's usage block (inputTokens/outputTokens).

Cursor Agent does not expose an image-attach flag, so cursor-cli is not a vision backend — images fall through the same non-vision text-reference path as other non-vision backends.

Diff

5 files: graphify/llm.py (+150), tests/test_cursor_cli_backend.py (+221), graphify/cli.py (+10, credential gate), graphify/__main__.py (+2/−2, help text), CHANGELOG.md (+4).

Testing

  • tests/test_cursor_cli_backend.py: 18 tests, mocked subprocess.run + shutil.which (no binary, no network) — parse/token accounting, error-envelope surfacing on zero and nonzero exit, garbage-envelope failure, _call_llm labeling path, usage accumulation, dispatch through extract_files_direct, zero-cost registration, stdin/argv shape, model pinning, user-turn delivery with <untrusted_source> guardrails intact.
  • uv run pytest tests/test_cursor_cli_backend.py tests/test_claude_cli_backend.py tests/test_llm_backends.py: 156 passed.
  • Full suite: 3591 passed, 70 skipped, 1 pre-existing failure on pristine v8 (test_ollama_retry_cap imports the optional openai SDK; reproduces on a clean checkout without this branch — environment, not the patch).
  • Live end-to-end against a real authenticated cursor-agent install: extraction of a Markdown corpus returned a well-formed graph with usage tracking; cost $0 (plan-billed).

One honest caveat

Live-verified against one person's Cursor install on macOS; like #3073, results depend on the CLI's JSON envelope staying stable. AGENTS.md followed: graphify update . run after the code changes.

…ly authenticated Cursor Agent CLI

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 Graphify-Labs#2554 handling in claude-cli.
GRAPHIFY_CURSOR_CLI_MODEL pins a model; the default stays Cursor's own auto
routing. Complements Graphify-Labs#3073 (openai-cli) with the Cursor equivalent.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) tested, no difference found (not proven).


Graphify review — findings

Adds a cursor-cli backend that routes semantic extraction and community labeling through the locally authenticated cursor-agent -p CLI, billing work to a Cursor subscription rather than a metered API key — modeled on claude-cli with zero pricing, ask-mode read-only access, --trust for non-interactive runs, and the prompt sent over stdin to dodge argv size limits. Extraction instructions ride in the user turn via _cli_extraction_prompt (system-prompt delivery gets diluted by local agent context and parses hollow), with GRAPHIFY_CURSOR_CLI_MODEL pinning a model and GRAPHIFY_CURSOR_CLI_PARALLEL=1 opting out of the forced-serial default. Failures surface loudly: a nonzero exit or is_error envelope raises with the CLI's own error text, and an unparseable JSON envelope on success errors rather than parsing to an empty graph the hollow-retry path would bisect forever.

Worth a look

  • cursor-cli backend not forced to serial concurrencygraphify/cli.py:3689 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Untrusted source text is sent to Cursor Agent with workspace trust enabledgraphify/llm.py:1827 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Generic Cursor LLM path also forces trusted agent executiongraphify/llm.py:3044 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • cursor-cli model flag uses mdl but guards on model, ignoring config defaultgraphify/llm.py:3049 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Default-model test depends on external environmenttests/test_cursor_cli_backend.py:167 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1133 functions depend on the 463 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 62 callers, 13 callees
  • new: extract_files_direct() — 18 callers, 21 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: _call_llm() — 14 callers, 18 callees
  • new: dispatch_command() — 2 callers, 123 callees
  • new: _extract_with_adaptive_retry() — 22 callers, 10 callees
  • …and 19 more — each is listed as a finding

Verification — 1133 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1069 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_call\_llm.

The verifier did not have enough to check \_call\_llm, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in detect\_backend (not a proof).

The verifier ran both versions of detect\_backend on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify extract\_corpus\_parallel.

The verifier did not have enough to check extract\_corpus\_parallel, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify extract\_files\_direct.

The verifier did not have enough to check extract\_files\_direct, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify label\_communities.

The verifier did not have enough to check label\_communities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 49 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).

The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 1 grounded finding(s) anchored inline below; 26 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/llm.py
)


def _call_cursor_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_call_cursor_cli()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

…ing 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.
@Ketlark

Ketlark commented Sep 3, 2026

Copy link
Copy Markdown
Author

Ran the missing executions on the gate's five findings. One was valid and is fixed in 1693e18; two don't reproduce at head; two are deliberate parity with the shipped claude-cli backend.

1. "cursor-cli backend not forced to serial concurrency" (cli.py:3689) — does not reproduce. The gate lives in llm.py, not at the credential check the finding anchors to. Both concurrency surfaces are forced-serial for cursor-cli, mirroring claude-cli:

  • graphify/llm.py:2723extract_corpus_parallel: if backend == "cursor-cli" and os.environ.get("GRAPHIFY_CURSOR_CLI_PARALLEL", "").strip() != "1": max_concurrency = 1
  • graphify/llm.py:3503 — community labeling: same guard

Execution: grep -n GRAPHIFY_CURSOR_CLI_PARALLEL graphify/llm.py returns exactly those two sites; the claude-cli counterpart guards sit directly above each.

2 & 3. "Untrusted source text is sent to Cursor Agent with workspace trust enabled" / "Generic Cursor LLM path also forces trusted agent execution" (llm.py:1827, 3044) — accurate observation of deliberate, documented behavior shared with the shipped claude-cli backend. --mode ask confines the agent to read-only tools — strictly less capable than the shipped claude-cli backend, which runs claude -p with default tool permissions and even allowlists extra directories via --add-dir (llm.py, claude-cli images path). The corpus-to-prompt injection surface is identical in kind to claude-cli; ask mode reduces the blast radius relative to it. The docstring in _call_cursor_cli documents the ask-mode choice.

4. "cursor-cli model flag uses mdl but guards on model, ignoring config default" (llm.py:3049) — deliberate parity. BACKENDS["cursor-cli"]["default_model"] is a display placeholder (cursor-agent-plan), not a real model — passing it to --model would break auto routing. The model is not None guard means "caller explicitly pinned a model", which is exactly the shipped claude-cli semantic: llm.py:3009-3010 (if model is not None: cli_args.extend(["--model", mdl])) — my branch mirrors it 1:1 at 3051-3052.

5. "Default-model test depends on external environment" (tests/test_cursor_cli_backend.py:167) — valid, fixed in 1693e18. test_no_model_flag_by_default now scrubs the variable via monkeypatch.delenv("GRAPHIFY_CURSOR_CLI_MODEL", raising=False), mirroring the existing GRAPHIFY_API_TIMEOUT delenv precedent in tests/test_claude_cli_backend.py:381. Reproduction of the fix under a hostile environment:

GRAPHIFY_CURSOR_CLI_MODEL=leak-test uv run pytest tests/test_cursor_cli_backend.py -q
→ 18 passed

Pre-fix, an exported GRAPHIFY_CURSOR_CLI_MODEL injected --model into the subprocess argv and failed the assertion.

Full suite re-run at head: tests/test_cursor_cli_backend.py tests/test_claude_cli_backend.py tests/test_llm_backends.py → 156 passed.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 3 advisory finding(s) below merit a look before merge.

Formal verification. 2 change(s) tested, no difference found (not proven).


Graphify review — findings

Adds a cursor-cli backend that runs semantic extraction and community labeling through the locally authenticated Cursor Agent CLI (cursor-agent -p --trust --output-format json --mode ask), billing work to a Cursor subscription rather than a metered API key. _call_cursor_cli delivers the extraction schema and imperative in the user turn (mirroring claude-cli), pipes the prompt over stdin to dodge argv limits, and reads text and token counts from the JSON envelope's result/usage; a nonzero exit or is_error envelope raises with the CLI's error detail, and unparseable stdout on success fails loudly instead of yielding an empty graph. Requires cursor-agent on $PATH (dispatch errors early if missing), forces serial calls unless GRAPHIFY_CURSOR_CLI_PARALLEL=1, honors GRAPHIFY_CURSOR_CLI_MODEL, and is not a vision backend since images aren't attached natively.

Worth a look

  • cursor-cli direct extraction ignores the requested modelgraphify/llm.py:2062 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • cursor-cli model flag uses mdl but guards on modelgraphify/llm.py:3048 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • cursor-cli --model uses wrong variable when model is Nonegraphify/llm.py:3049 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1133 functions depend on the 463 functions this change touches.

Health — this change adds coupling hotspots:

  • new: deduplicate_entities() — 63 callers, 21 callees
  • new: build_merge() — 62 callers, 13 callees
  • new: extract_files_direct() — 18 callers, 21 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: _call_claude_cli() — 31 callers, 9 callees
  • new: _call_llm() — 14 callers, 18 callees
  • new: dispatch_command() — 2 callers, 123 callees
  • new: _extract_with_adaptive_retry() — 22 callers, 10 callees
  • …and 19 more — each is listed as a finding

Verification — 1133 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1069 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_call\_llm.

The verifier did not have enough to check \_call\_llm, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in detect\_backend (not a proof).

The verifier ran both versions of detect\_backend on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify extract\_corpus\_parallel.

The verifier did not have enough to check extract\_corpus\_parallel, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify extract\_files\_direct.

The verifier did not have enough to check extract\_files\_direct, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify label\_communities.

The verifier did not have enough to check label\_communities, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 49 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in \_run\_cli (not a proof).

The verifier ran both versions of \_run\_cli on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 1 grounded finding(s) anchored inline below; 26 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/llm.py
)


def _call_cursor_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_call_cursor_cli()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant