Add Gemini CLI support and sponsor nudge at pipeline completion (#105)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-04-09 17:40:45 +01:00
parent 38381597d6
commit df26f85648
12 changed files with 189 additions and 2 deletions
+5
View File
@@ -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)
+1 -1
View File
@@ -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).
+113
View File
@@ -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":
+2
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -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:
+2
View File
@@ -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:
+1 -1
View File
@@ -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" }
+55
View File
@@ -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