Add Cursor support, fix _rebuild_code KeyError and node_link_data crash (#137, #148, #149)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-09 17:19:40 +01:00
co-authored by Claude Sonnet 4.6
parent c99ac6c2df
commit df77d5f8ce
7 changed files with 104 additions and 5 deletions
+7
View File
@@ -2,6 +2,13 @@
Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases)
## 0.3.22 (2026-04-09)
- Add: Cursor support — `graphify cursor install` writes `.cursor/rules/graphify.mdc` with `alwaysApply: true` so the graph context is always included; `graphify cursor uninstall` removes it (#137)
- Fix: `_rebuild_code()` KeyError — `detected[FileType.CODE]` corrected to `detected['files']['code']` matching `detect()`'s actual return shape; was silently breaking git hooks on every commit (#148)
- Fix: `to_json()` crash on NetworkX 3.2.x — `node_link_data(G, edges="links")` now falls back to `node_link_data(G)` on older NetworkX, same shim already used for `node_link_graph` (#149)
- Fix: README clarifies `graphifyy` is the only official PyPI package — other `graphify*` packages are not affiliated (#129)
## 0.3.21 (2026-04-09)
- Fix: Codex PreToolUse hook now places `systemMessage` at the top level of the output JSON instead of inside `hookSpecificOutput` — matches the strict schema enforced by codex-cli 0.118.0+ which uses `additionalProperties: false` (#138)
+2 -1
View File
@@ -53,7 +53,7 @@ Every relationship is tagged `EXTRACTED` (found directly in source), `INFERRED`
pip install graphifyy && graphify install
```
> The PyPI package is temporarily named `graphifyy` while the `graphify` name is being reclaimed. The CLI and skill command are still `graphify`.
> **Official package:** The PyPI package is named `graphifyy` (install with `pip install graphifyy`). Other packages named `graphify*` on PyPI are not affiliated with this project. The only official repository is [safishamsi/graphify](https://github.com/safishamsi/graphify). The CLI and skill command are still `graphify`.
### Platform support
@@ -91,6 +91,7 @@ After building a graph, run this once in your project:
| Factory Droid | `graphify droid install` |
| Trae | `graphify trae install` |
| Trae CN | `graphify trae-cn install` |
| Cursor | `graphify cursor install` |
**Claude Code** does two things: writes a `CLAUDE.md` section telling Claude to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs a **PreToolUse hook** (`settings.json`) that fires before every Glob and Grep call. If a knowledge graph exists, Claude sees: _"graphify: Knowledge graph exists. Read GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ — so Claude navigates via the graph instead of grepping through every file.
+50
View File
@@ -160,6 +160,45 @@ Rules:
_AGENTS_MD_MARKER = "## graphify"
_CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc"
_CURSOR_RULE = """\
---
description: graphify knowledge graph context
alwaysApply: true
---
This project has a graphify knowledge graph at graphify-out/.
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
- After modifying code files in this session, run `python3 -c "from graphify.watch import _rebuild_code; from pathlib import Path; _rebuild_code(Path('.'))"` to keep the graph current
"""
def _cursor_install(project_dir: Path) -> None:
"""Write .cursor/rules/graphify.mdc with alwaysApply: true."""
rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
rule_path.parent.mkdir(parents=True, exist_ok=True)
if rule_path.exists():
print(f"graphify rule already exists at {rule_path} (no change)")
return
rule_path.write_text(_CURSOR_RULE, encoding="utf-8")
print(f"graphify rule written to {rule_path.resolve()}")
print()
print("Cursor will now always include the knowledge graph context.")
print("Run /graphify . first to build the graph if you haven't already.")
def _cursor_uninstall(project_dir: Path) -> None:
"""Remove .cursor/rules/graphify.mdc."""
rule_path = (project_dir or Path(".")) / _CURSOR_RULE_PATH
if not rule_path.exists():
print("No graphify Cursor rule found - nothing to do")
return
rule_path.unlink()
print(f"graphify Cursor rule removed from {rule_path.resolve()}")
# OpenCode tool.execute.before plugin — fires before every tool call.
# Injects a graph reminder into bash command output when graph.json exists.
_OPENCODE_PLUGIN_JS = """\
@@ -483,6 +522,8 @@ def main() -> None:
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
print(" hook uninstall remove git hooks")
print(" hook status check if git hooks are installed")
print(" cursor install write .cursor/rules/graphify.mdc (Cursor)")
print(" cursor uninstall remove .cursor/rules/graphify.mdc")
print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)")
print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook")
print(" codex install write graphify section to AGENTS.md (Codex)")
@@ -526,6 +567,15 @@ def main() -> None:
else:
print("Usage: graphify claude [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd == "cursor":
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
_cursor_install(Path("."))
elif subcmd == "uninstall":
_cursor_uninstall(Path("."))
else:
print("Usage: graphify cursor [install|uninstall]", file=sys.stderr)
sys.exit(1)
elif cmd in ("codex", "opencode", "claw", "droid", "trae", "trae-cn"):
subcmd = sys.argv[2] if len(sys.argv) > 2 else ""
if subcmd == "install":
+4 -1
View File
@@ -284,7 +284,10 @@ def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str) -> None:
node_community = _node_community_map(communities)
data = json_graph.node_link_data(G, edges="links")
try:
data = json_graph.node_link_data(G, edges="links")
except TypeError:
data = json_graph.node_link_data(G)
for node in data["nodes"]:
node["community"] = node_community.get(node["id"])
for link in data["links"]:
+2 -2
View File
@@ -18,7 +18,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
"""
try:
from graphify.extract import extract
from graphify.detect import detect, FileType
from graphify.detect import detect
from graphify.build import build_from_json
from graphify.cluster import cluster, score_all
from graphify.analyze import god_nodes, surprising_connections, suggest_questions
@@ -26,7 +26,7 @@ def _rebuild_code(watch_path: Path, *, follow_symlinks: bool = False) -> bool:
from graphify.export import to_json
detected = detect(watch_path, follow_symlinks=follow_symlinks)
code_files = [Path(f) for f in detected[FileType.CODE]]
code_files = [Path(f) for f in detected['files']['code']]
if not code_files:
print("[graphify watch] No code files found - nothing to rebuild.")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "graphifyy"
version = "0.3.21"
version = "0.3.22"
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, OpenClaw) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
readme = "README.md"
license = { file = "LICENSE" }
+38
View File
@@ -225,3 +225,41 @@ def test_opencode_agents_uninstall_removes_plugin(tmp_path):
if config_file.exists():
config = _json.loads(config_file.read_text())
assert not any("graphify.js" in p for p in config.get("plugin", []))
# ── Cursor ────────────────────────────────────────────────────────────────────
def test_cursor_install_writes_rule(tmp_path):
"""cursor install writes .cursor/rules/graphify.mdc."""
from graphify.__main__ import _cursor_install
_cursor_install(tmp_path)
rule = tmp_path / ".cursor" / "rules" / "graphify.mdc"
assert rule.exists()
content = rule.read_text()
assert "alwaysApply: true" in content
assert "graphify-out/GRAPH_REPORT.md" in content
def test_cursor_install_idempotent(tmp_path):
"""cursor install does not overwrite an existing rule file."""
from graphify.__main__ import _cursor_install
_cursor_install(tmp_path)
rule = tmp_path / ".cursor" / "rules" / "graphify.mdc"
original = rule.read_text()
_cursor_install(tmp_path)
assert rule.read_text() == original
def test_cursor_uninstall_removes_rule(tmp_path):
"""cursor uninstall removes the rule file."""
from graphify.__main__ import _cursor_install, _cursor_uninstall
_cursor_install(tmp_path)
_cursor_uninstall(tmp_path)
rule = tmp_path / ".cursor" / "rules" / "graphify.mdc"
assert not rule.exists()
def test_cursor_uninstall_noop_if_not_installed(tmp_path):
"""cursor uninstall does nothing if rule was never written."""
from graphify.__main__ import _cursor_uninstall
_cursor_uninstall(tmp_path) # should not raise