feat(llm): cursor-cli backend — semantic extraction through the locally authenticated Cursor Agent CLI - #3311
feat(llm): cursor-cli backend — semantic extraction through the locally authenticated Cursor Agent CLI#3311Ketlark wants to merge 2 commits into
Conversation
…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.
There was a problem hiding this comment.
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 concurrency —
graphify/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 enabled —
graphify/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 execution —
graphify/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 default —
graphify/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 environment —
tests/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).
| ) | ||
|
|
||
|
|
||
| def _call_cursor_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: |
There was a problem hiding this comment.
_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.
|
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:
Execution: 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. 4. "cursor-cli model flag uses mdl but guards on model, ignoring config default" (llm.py:3049) — deliberate parity. 5. "Default-model test depends on external environment" (tests/test_cursor_cli_backend.py:167) — valid, fixed in 1693e18. Pre-fix, an exported Full suite re-run at head: |
There was a problem hiding this comment.
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 model —
graphify/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 model —
graphify/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 None —
graphify/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).
| ) | ||
|
|
||
|
|
||
| def _call_cursor_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bool = False, images: list[_ImageRef] | None = None) -> dict: |
There was a problem hiding this comment.
_call_cursor_cli()
14 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
Complements #3073 (openai-cli, Codex side) with the Cursor equivalent. Same shape as the existing
claude-clibackend: 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-cliruns semantic extraction and community labeling throughcursor-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:
MAX_ARG_STRLEN), so argv delivery fails withE2BIGon ordinary input.--mode askkeeps the agent read-only. An agentic CLI given write access to the corpus it is reading can modify what it is extracting from.--trustsatisfies non-interactive workspace trust (headless runs abort with a trust prompt otherwise).cursor-agentflags API failures withis_errorin the stdout envelope while exiting 0. Parsingresultas 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 blankexited 1:, keeps retrying against the outage, and can land in graph.json as a community label #2554 handling in claude-cli.GRAPHIFY_CURSOR_CLI_MODELno--modelflag is passed and Cursor's ownautorouting decides; the env var pins a specific model when wanted.Token usage is read from the JSON envelope's
usageblock (inputTokens/outputTokens).Cursor Agent does not expose an image-attach flag, so
cursor-cliis 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, mockedsubprocess.run+shutil.which(no binary, no network) — parse/token accounting, error-envelope surfacing on zero and nonzero exit, garbage-envelope failure,_call_llmlabeling path, usage accumulation, dispatch throughextract_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.openaiSDK; reproduces on a clean checkout without this branch — environment, not the patch).cursor-agentinstall: 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.mdfollowed:graphify update .run after the code changes.