From df26f856483cf4f86265632f7225f302d377533d Mon Sep 17 00:00:00 2001 From: Safi Date: Thu, 9 Apr 2026 17:40:45 +0100 Subject: [PATCH] Add Gemini CLI support and sponsor nudge at pipeline completion (#105) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 ++ README.md | 2 +- graphify/__main__.py | 113 +++++++++++++++++++++++++++++++++++++ graphify/skill-claw.md | 2 + graphify/skill-codex.md | 2 + graphify/skill-droid.md | 2 + graphify/skill-opencode.md | 2 + graphify/skill-trae.md | 2 + graphify/skill-windows.md | 2 + graphify/skill.md | 2 + pyproject.toml | 2 +- tests/test_install.py | 55 ++++++++++++++++++ 12 files changed, 189 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f0722e..147960c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.3.23 (2026-04-09) + +- Add: Gemini CLI support — `graphify gemini install` writes a `GEMINI.md` section and a `BeforeTool` hook in `.gemini/settings.json` that fires before file-read tool calls (#105) +- Add: sponsor nudge at pipeline completion — all skill files now print a one-line sponsor link after a fresh build, not on `--update` runs + ## 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) diff --git a/README.md b/README.md index d81b430c..e5a13d88 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Downloads](https://img.shields.io/pypi/dm/graphifyy)](https://pypi.org/project/graphifyy/) [![Sponsor](https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors)](https://github.com/sponsors/safishamsi) -**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, or Trae - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions. +**An AI coding assistant skill.** Type `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, OpenClaw, Factory Droid, or Trae - it reads your files, builds a knowledge graph, and gives you back structure you didn't know was there. Understand a codebase faster. Find the "why" behind architectural decisions. Fully multimodal. Drop in code, PDFs, markdown, screenshots, diagrams, whiteboard photos, even images in other languages - graphify uses Claude vision to extract concepts and relationships from all of it and connects them into one graph. 20 languages supported via tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia). diff --git a/graphify/__main__.py b/graphify/__main__.py index 31f54fff..c2c27d4a 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -160,6 +160,108 @@ Rules: _AGENTS_MD_MARKER = "## graphify" +_GEMINI_MD_SECTION = """\ +## graphify + +This project has a graphify knowledge graph at graphify-out/. + +Rules: +- 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 +""" + +_GEMINI_MD_MARKER = "## graphify" + +_GEMINI_HOOK = { + "matcher": "read_file|list_directory", + "hooks": [ + { + "type": "command", + "command": ( + "[ -f graphify-out/graph.json ] && " + r"""echo '{"decision":"allow","additionalContext":"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."}' """ + r"""|| echo '{"decision":"allow"}'""" + ), + } + ], +} + + +def gemini_install(project_dir: Path | None = None) -> None: + """Write the graphify section to GEMINI.md and install BeforeTool hook.""" + target = (project_dir or Path(".")) / "GEMINI.md" + + if target.exists(): + content = target.read_text(encoding="utf-8") + if _GEMINI_MD_MARKER in content: + print("graphify already configured in GEMINI.md") + else: + target.write_text(content.rstrip() + "\n\n" + _GEMINI_MD_SECTION, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") + else: + target.write_text(_GEMINI_MD_SECTION, encoding="utf-8") + print(f"graphify section written to {target.resolve()}") + + _install_gemini_hook(project_dir or Path(".")) + print() + print("Gemini CLI will now check the knowledge graph before answering") + print("codebase questions and rebuild it after code changes.") + + +def _install_gemini_hook(project_dir: Path) -> None: + settings_path = project_dir / ".gemini" / "settings.json" + settings_path.parent.mkdir(parents=True, exist_ok=True) + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) if settings_path.exists() else {} + except json.JSONDecodeError: + settings = {} + before_tool = settings.setdefault("hooks", {}).setdefault("BeforeTool", []) + if any("graphify" in str(h) for h in before_tool): + print(" .gemini/settings.json -> hook already registered (no change)") + return + before_tool.append(_GEMINI_HOOK) + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(" .gemini/settings.json -> BeforeTool hook registered") + + +def _uninstall_gemini_hook(project_dir: Path) -> None: + settings_path = project_dir / ".gemini" / "settings.json" + if not settings_path.exists(): + return + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return + before_tool = settings.get("hooks", {}).get("BeforeTool", []) + filtered = [h for h in before_tool if "graphify" not in str(h)] + if len(filtered) == len(before_tool): + return + settings["hooks"]["BeforeTool"] = filtered + settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8") + print(" .gemini/settings.json -> BeforeTool hook removed") + + +def gemini_uninstall(project_dir: Path | None = None) -> None: + """Remove the graphify section from GEMINI.md and uninstall hook.""" + target = (project_dir or Path(".")) / "GEMINI.md" + if not target.exists(): + print("No GEMINI.md found in current directory - nothing to do") + return + content = target.read_text(encoding="utf-8") + if _GEMINI_MD_MARKER not in content: + print("graphify section not found in GEMINI.md - nothing to do") + return + cleaned = re.sub(r"\n*## graphify\n.*?(?=\n## |\Z)", "", content, flags=re.DOTALL).rstrip() + if cleaned: + target.write_text(cleaned + "\n", encoding="utf-8") + print(f"graphify section removed from {target.resolve()}") + else: + target.unlink() + print(f"GEMINI.md was empty after removal - deleted {target.resolve()}") + _uninstall_gemini_hook(project_dir or Path(".")) + + _CURSOR_RULE_PATH = Path(".cursor") / "rules" / "graphify.mdc" _CURSOR_RULE = """\ --- @@ -522,6 +624,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(" gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)") + print(" gemini uninstall remove GEMINI.md section + BeforeTool hook") 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)") @@ -567,6 +671,15 @@ def main() -> None: else: print("Usage: graphify claude [install|uninstall]", file=sys.stderr) sys.exit(1) + elif cmd == "gemini": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd == "install": + gemini_install() + elif subcmd == "uninstall": + gemini_uninstall() + else: + print("Usage: graphify gemini [install|uninstall]", file=sys.stderr) + sys.exit(1) elif cmd == "cursor": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" if subcmd == "install": diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index eb340952..73eff7f3 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -608,6 +608,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index 12f17c48..d14a90bf 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -664,6 +664,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index 42647e66..b36399db 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -661,6 +661,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index 1f07cca7..ad431840 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -660,6 +660,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index c0cfffb9..2711dcd1 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -639,6 +639,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index e0955c61..1f174cb8 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -651,6 +651,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/graphify/skill.md b/graphify/skill.md index 84815de7..72d0f2da 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -664,6 +664,8 @@ Graph complete. Outputs in PATH_TO_DIR/graphify-out/ obsidian/ - Obsidian vault (only if --obsidian was given) ``` +If graphify saved you time, consider supporting it: https://github.com/sponsors/safishamsi + Replace PATH_TO_DIR with the actual absolute path of the directory that was processed. Then paste these sections from GRAPH_REPORT.md directly into the chat: diff --git a/pyproject.toml b/pyproject.toml index 93f20cf2..a18d0873 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.3.22" +version = "0.3.23" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, OpenClaw, Factory Droid, Trae) - 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 34d1761e..f8353ac4 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -263,3 +263,58 @@ 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 + + +# ── Gemini CLI ──────────────────────────────────────────────────────────────── + +def test_gemini_install_writes_gemini_md(tmp_path): + from graphify.__main__ import gemini_install + gemini_install(tmp_path) + md = tmp_path / "GEMINI.md" + assert md.exists() + assert "graphify-out/GRAPH_REPORT.md" in md.read_text() + +def test_gemini_install_writes_hook(tmp_path): + import json as _json + from graphify.__main__ import gemini_install + gemini_install(tmp_path) + settings = _json.loads((tmp_path / ".gemini" / "settings.json").read_text()) + hooks = settings["hooks"]["BeforeTool"] + assert any("graphify" in str(h) for h in hooks) + +def test_gemini_install_idempotent(tmp_path): + from graphify.__main__ import gemini_install + gemini_install(tmp_path) + gemini_install(tmp_path) + md = tmp_path / "GEMINI.md" + assert md.read_text().count("## graphify") == 1 + +def test_gemini_install_merges_existing_gemini_md(tmp_path): + from graphify.__main__ import gemini_install + (tmp_path / "GEMINI.md").write_text("# My project rules\n") + gemini_install(tmp_path) + content = (tmp_path / "GEMINI.md").read_text() + assert "# My project rules" in content + assert "graphify-out/GRAPH_REPORT.md" in content + +def test_gemini_uninstall_removes_section(tmp_path): + from graphify.__main__ import gemini_install, gemini_uninstall + gemini_install(tmp_path) + gemini_uninstall(tmp_path) + md = tmp_path / "GEMINI.md" + assert not md.exists() + +def test_gemini_uninstall_removes_hook(tmp_path): + import json as _json + from graphify.__main__ import gemini_install, gemini_uninstall + gemini_install(tmp_path) + gemini_uninstall(tmp_path) + settings_path = tmp_path / ".gemini" / "settings.json" + if settings_path.exists(): + settings = _json.loads(settings_path.read_text()) + hooks = settings.get("hooks", {}).get("BeforeTool", []) + assert not any("graphify" in str(h) for h in hooks) + +def test_gemini_uninstall_noop_if_not_installed(tmp_path): + from graphify.__main__ import gemini_uninstall + gemini_uninstall(tmp_path) # should not raise