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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,13 +571,13 @@ def _run_cli() -> None:
print(" --no-label keep 'Community N' placeholders (skip LLM community naming)")
print(" --backend=<name> backend to use for community naming (default: auto-detect)")
print(" --model=<name> 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 <path> (re)name communities with the configured LLM backend, regenerate report")
print(" --missing-only keep existing labels and only name missing/placeholder communities")
print(" --backend=<name> backend to use (default: auto-detect from API keys)")
print(" --model=<name> 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 \"<question>\" BFS traversal of graph.json for a question")
print(" --dfs use depth-first instead of breadth-first")
Expand Down
10 changes: 10 additions & 0 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
155 changes: 152 additions & 3 deletions graphify/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}


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

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.

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.

"""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:
Expand Down Expand Up @@ -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=."
Expand All @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)}."
)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Loading