diff --git a/CHANGELOG.md b/CHANGELOG.md index 55d8fb06..82f0722e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 6423a877..f018f02c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/graphify/__main__.py b/graphify/__main__.py index 3f31b7cc..31f54fff 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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": diff --git a/graphify/export.py b/graphify/export.py index 16271a73..498e514a 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -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"]: diff --git a/graphify/watch.py b/graphify/watch.py index a0262527..734de8bf 100644 --- a/graphify/watch.py +++ b/graphify/watch.py @@ -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.") diff --git a/pyproject.toml b/pyproject.toml index 627abc43..6a06bf43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/tests/test_install.py b/tests/test_install.py index 04c2cda1..34d1761e 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -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