diff --git a/README.md b/README.md
index 0724903..f29a8f8 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ To target a specific platform:
code-review-graph install --platform codex # configure only Codex
code-review-graph install --platform cursor # configure only Cursor
code-review-graph install --platform claude-code # configure only Claude Code
+code-review-graph install --platform gemini-cli # configure only Gemini CLI
code-review-graph install --platform kiro # configure only Kiro
```
@@ -511,5 +512,5 @@ MIT. See [LICENSE](LICENSE).
code-review-graph.com
pip install code-review-graph && code-review-graph install
-Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, and Kiro
+Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, and Kiro
diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py
index 18e9d52..d3baf97 100644
--- a/code_review_graph/cli.py
+++ b/code_review_graph/cli.py
@@ -52,7 +52,7 @@
# Shared platform choices for install and init commands
_PLATFORM_CHOICES = [
"codex", "claude", "claude-code", "cursor", "windsurf", "zed",
- "continue", "opencode", "antigravity", "qwen", "kiro", "qoder", "all",
+ "continue", "opencode", "antigravity", "gemini-cli", "qwen", "kiro", "qoder", "all",
]
@@ -227,6 +227,8 @@ def _handle_init(args: argparse.Namespace) -> None:
from .skills import (
PLATFORMS,
generate_skills,
+ install_gemini_cli_hooks,
+ install_gemini_cli_skills,
inject_claude_md,
inject_platform_instructions,
install_cursor_hooks,
@@ -237,8 +239,15 @@ def _handle_init(args: argparse.Namespace) -> None:
)
if not skip_skills:
- skills_dir = generate_skills(repo_root)
- print(f"Generated skills in {skills_dir}")
+ # Claude Code skills are only relevant for Claude (or full install).
+ if target in ("claude", "all"):
+ skills_dir = generate_skills(repo_root)
+ print(f"Generated Claude Code skills in {skills_dir}")
+
+ # Gemini CLI skills are workspace-scoped under .gemini/.
+ if target in ("gemini-cli", "all"):
+ gemini_skills_dir = install_gemini_cli_skills(repo_root)
+ print(f"Installed Gemini CLI skills in {gemini_skills_dir}")
# Confirm before writing instruction files (#173). --yes skips the
# prompt; --no-instructions skips the whole block.
@@ -283,6 +292,13 @@ def _handle_init(args: argparse.Namespace) -> None:
except Exception as exc:
logger.warning("Could not install Cursor hooks: %s", exc)
+ if not skip_hooks and target in ("gemini-cli", "all"):
+ try:
+ gemini_settings = install_gemini_cli_hooks(repo_root)
+ print(f"Installed Gemini CLI hooks in {gemini_settings}")
+ except Exception as exc:
+ logger.warning("Could not install Gemini CLI hooks: %s", exc)
+
# OpenCode plugin (user-level, gated by same detect() as MCP config)
if not skip_hooks and target in ("all", "opencode") and PLATFORMS["opencode"]["detect"]():
try:
@@ -332,12 +348,12 @@ def main() -> None:
install_cmd.add_argument(
"--no-skills",
action="store_true",
- help="Skip generating Claude Code skill files",
+ help="Skip generating platform skill files",
)
install_cmd.add_argument(
"--no-hooks",
action="store_true",
- help="Skip installing Claude Code hooks",
+ help="Skip installing platform hooks",
)
install_cmd.add_argument(
"--no-instructions",
@@ -373,12 +389,12 @@ def main() -> None:
init_cmd.add_argument(
"--no-skills",
action="store_true",
- help="Skip generating Claude Code skill files",
+ help="Skip generating platform skill files",
)
init_cmd.add_argument(
"--no-hooks",
action="store_true",
- help="Skip installing Claude Code hooks",
+ help="Skip installing platform hooks",
)
init_cmd.add_argument(
"--no-instructions",
diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py
index 6626553..dcc6161 100644
--- a/code_review_graph/skills.py
+++ b/code_review_graph/skills.py
@@ -96,6 +96,14 @@ def _zed_settings_path() -> Path:
"format": "object",
"needs_type": False,
},
+ "gemini-cli": {
+ "name": "Gemini CLI",
+ "config_path": lambda root: root / ".gemini" / "settings.json",
+ "key": "mcpServers",
+ "detect": lambda: bool(shutil.which("gemini")) or (Path.home() / ".gemini").exists(),
+ "format": "object",
+ "needs_type": False,
+ },
"qwen": {
"name": "Qwen Code",
"config_path": lambda root: Path.home() / ".qwen" / "settings.json",
@@ -720,7 +728,7 @@ def inject_claude_md(repo_root: Path) -> None:
# whose owner set includes the target (or "all") are written.
_PLATFORM_INSTRUCTION_FILES: dict[str, tuple[str, ...]] = {
"AGENTS.md": ("cursor", "opencode", "antigravity"),
- "GEMINI.md": ("antigravity",),
+ "GEMINI.md": ("antigravity", "gemini-cli"),
".cursorrules": ("cursor",),
".windsurfrules": ("windsurf",),
"QODER.md": ("qoder",),
@@ -728,6 +736,156 @@ def inject_claude_md(repo_root: Path) -> None:
}
+# --- Gemini CLI hooks + skills (workspace-level: .gemini/) ---
+
+
+def install_gemini_cli_hooks(repo_root: Path) -> Path:
+ """Install Gemini CLI hooks in .gemini/settings.json and write hook scripts.
+
+ Hooks schema reference:
+ - https://geminicli.com/docs/hooks/reference/
+
+ This is workspace-scoped (project) configuration: .gemini/settings.json
+ """
+ settings_dir = repo_root / ".gemini"
+ settings_dir.mkdir(parents=True, exist_ok=True)
+ settings_path = settings_dir / "settings.json"
+
+ existing: dict[str, Any] = {}
+ if settings_path.exists():
+ try:
+ existing = json.loads(settings_path.read_text(encoding="utf-8", errors="replace"))
+ backup_path = settings_dir / "settings.json.bak"
+ shutil.copy2(settings_path, backup_path)
+ logger.info("Backed up existing Gemini CLI settings to %s", backup_path)
+ except (json.JSONDecodeError, OSError) as exc:
+ logger.warning("Could not read existing %s: %s", settings_path, exc)
+
+ hooks_dir = settings_dir / "hooks"
+ hooks_dir.mkdir(parents=True, exist_ok=True)
+
+ repo_arg = repo_root.resolve().as_posix()
+ session_start_script = """\
+#!/usr/bin/env bash
+# code-review-graph: session start status (Gemini CLI hook)
+# Must output ONLY JSON on stdout. Logs go to stderr. Never blocks the session.
+set -euo pipefail
+
+cat > /dev/null || true
+
+msg="$(code-review-graph status --repo "__CRG_REPO__" 2>&1 | head -n 1 || true)"
+
+CRG_MSG="$msg" python3 -c 'import json, os; print(json.dumps({"systemMessage": os.environ.get("CRG_MSG",""), "suppressOutput": True}))' 2>/dev/null || echo '{"suppressOutput": true}'
+exit 0
+"""
+ session_start_script = session_start_script.replace("__CRG_REPO__", repo_arg)
+
+ update_script = """\
+#!/usr/bin/env bash
+# code-review-graph: incremental update after write/replace (Gemini CLI hook)
+# Must output ONLY JSON on stdout. Low-noise: no systemMessage.
+set -euo pipefail
+
+cat > /dev/null || true
+
+code-review-graph update --skip-flows --repo "__CRG_REPO__" >/dev/null 2>&1 || true
+echo '{"suppressOutput": true}'
+exit 0
+"""
+ update_script = update_script.replace("__CRG_REPO__", repo_arg)
+
+ session_start_path = hooks_dir / "crg-session-start.sh"
+ session_start_path.write_text(session_start_script, encoding="utf-8")
+ session_start_path.chmod(0o755)
+
+ update_path = hooks_dir / "crg-update.sh"
+ update_path.write_text(update_script, encoding="utf-8")
+ update_path.chmod(0o755)
+
+ hooks_obj = existing.get("hooks", {})
+ if not isinstance(hooks_obj, dict):
+ hooks_obj = {}
+
+ def _ensure_group(event_name: str, matcher: str, hook_command: str, name: str, timeout: int) -> None:
+ arr = hooks_obj.get(event_name, [])
+ if not isinstance(arr, list):
+ arr = []
+
+ # De-duplicate by command (and type) inside nested hooks list.
+ def _group_has_command(group: Any) -> bool:
+ if not isinstance(group, dict):
+ return False
+ nested = group.get("hooks", [])
+ if not isinstance(nested, list):
+ return False
+ for h in nested:
+ if isinstance(h, dict) and h.get("type") == "command" and h.get("command") == hook_command:
+ return True
+ return False
+
+ if any(_group_has_command(g) for g in arr):
+ hooks_obj[event_name] = arr
+ return
+
+ arr.append(
+ {
+ "matcher": matcher,
+ "hooks": [
+ {
+ "type": "command",
+ "command": hook_command,
+ "name": name,
+ "timeout": timeout,
+ }
+ ],
+ }
+ )
+ hooks_obj[event_name] = arr
+
+ _ensure_group(
+ event_name="SessionStart",
+ matcher="",
+ hook_command="bash .gemini/hooks/crg-session-start.sh",
+ name="code-review-graph status",
+ timeout=10_000,
+ )
+ _ensure_group(
+ event_name="AfterTool",
+ matcher="write_file|replace",
+ hook_command="bash .gemini/hooks/crg-update.sh",
+ name="code-review-graph update",
+ timeout=30_000,
+ )
+
+ existing["hooks"] = hooks_obj
+ settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
+ logger.info("Wrote Gemini CLI hooks config: %s", settings_path)
+ return settings_path
+
+
+def install_gemini_cli_skills(repo_root: Path) -> Path:
+ """Install Gemini CLI Agent Skills in .gemini/skills//SKILL.md."""
+ skills_root = repo_root / ".gemini" / "skills"
+ skills_root.mkdir(parents=True, exist_ok=True)
+
+ for filename, skill in _SKILLS.items():
+ slug = filename.rsplit(".", 1)[0]
+ skill_dir = skills_root / slug
+ skill_dir.mkdir(parents=True, exist_ok=True)
+ skill_path = skill_dir / "SKILL.md"
+ content = (
+ "---\n"
+ f"name: {slug}\n"
+ f"description: {skill['description']}\n"
+ "---\n\n"
+ f"{skill['body']}\n"
+ )
+ skill_path.write_text(content, encoding="utf-8")
+ logger.info("Wrote Gemini CLI skill: %s", skill_path)
+
+ return skills_root
+
+
def inject_platform_instructions(repo_root: Path, target: str = "all") -> list[str]:
"""Inject 'use graph first' instructions into platform rule files.
diff --git a/docs/USAGE.md b/docs/USAGE.md
index e2fa865..8f3f40f 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -32,6 +32,7 @@ code-review-graph install --platform claude-code
| **Continue** | `.continue/config.json` |
| **OpenCode** | `.opencode.json` |
| **Antigravity** | `~/.gemini/antigravity/mcp_config.json` |
+| **Gemini CLI** | `.gemini/settings.json` |
| **Qwen Code** | `~/.qwen/settings.json` |
| **Qoder** | `.qoder/mcp.json` |
diff --git a/tests/test_skills.py b/tests/test_skills.py
index 873ed2f..12e199d 100644
--- a/tests/test_skills.py
+++ b/tests/test_skills.py
@@ -28,6 +28,8 @@
generate_skills,
inject_claude_md,
inject_platform_instructions,
+ install_gemini_cli_hooks,
+ install_gemini_cli_skills,
install_cursor_hooks,
install_git_hook,
install_hooks,
@@ -359,6 +361,14 @@ def test_antigravity_writes_agents_and_gemini(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="antigravity")
assert set(updated) == {"AGENTS.md", "GEMINI.md"}
+ def test_gemini_cli_writes_only_gemini_md(self, tmp_path):
+ updated = inject_platform_instructions(tmp_path, target="gemini-cli")
+ assert updated == ["GEMINI.md"]
+ assert not (tmp_path / "AGENTS.md").exists()
+ assert not (tmp_path / ".cursorrules").exists()
+ assert not (tmp_path / ".windsurfrules").exists()
+ assert not (tmp_path / "QODER.md").exists()
+
def test_opencode_writes_only_agents(self, tmp_path):
updated = inject_platform_instructions(tmp_path, target="opencode")
assert updated == ["AGENTS.md"]
@@ -532,6 +542,25 @@ def test_install_opencode_config(self, tmp_path):
assert entry["type"] == "stdio"
assert entry["env"] == []
+ def test_install_gemini_cli_config(self, tmp_path):
+ gemini_config = tmp_path / ".gemini" / "settings.json"
+ with patch.dict(
+ PLATFORMS,
+ {
+ "gemini-cli": {
+ **PLATFORMS["gemini-cli"],
+ "config_path": lambda root: gemini_config,
+ "detect": lambda: True,
+ },
+ },
+ ):
+ configured = install_platform_configs(tmp_path, target="gemini-cli")
+ assert "Gemini CLI" in configured
+ data = json.loads(gemini_config.read_text())
+ entry = data["mcpServers"]["code-review-graph"]
+ assert "type" not in entry
+ assert entry["args"][-1] == "serve"
+
def test_install_qwen_config(self, tmp_path):
"""Qwen Code uses ~/.qwen/settings.json with mcpServers (see #83)."""
qwen_config = tmp_path / ".qwen" / "settings.json"
@@ -593,6 +622,7 @@ def test_install_all_detected(self, tmp_path):
"zed": {**PLATFORMS["zed"], "detect": lambda: False},
"continue": {**PLATFORMS["continue"], "detect": lambda: False},
"antigravity": {**PLATFORMS["antigravity"], "detect": lambda: False},
+ "gemini-cli": {**PLATFORMS["gemini-cli"], "detect": lambda: False},
},
):
configured = install_platform_configs(tmp_path, target="all")
@@ -667,6 +697,41 @@ def test_install_qoder_config(self, tmp_path):
assert data["mcpServers"]["code-review-graph"]["command"] == expected_cmd
+class TestGeminiCLIInstall:
+ def test_install_gemini_cli_hooks_creates_settings_and_scripts(self, tmp_path):
+ settings_dir = tmp_path / ".gemini"
+ settings_dir.mkdir(parents=True, exist_ok=True)
+ settings_path = settings_dir / "settings.json"
+ settings_path.write_text(json.dumps({"customSetting": True}) + "\n", encoding="utf-8")
+
+ out_path = install_gemini_cli_hooks(tmp_path)
+ assert out_path == settings_path
+ assert (settings_dir / "settings.json.bak").exists()
+
+ data = json.loads(settings_path.read_text(encoding="utf-8"))
+ assert data["customSetting"] is True
+ assert "hooks" in data
+ assert "SessionStart" in data["hooks"]
+ assert "AfterTool" in data["hooks"]
+
+ session_start = settings_dir / "hooks" / "crg-session-start.sh"
+ update = settings_dir / "hooks" / "crg-update.sh"
+ assert session_start.exists()
+ assert update.exists()
+ assert os.access(session_start, os.X_OK)
+ assert os.access(update, os.X_OK)
+
+ def test_install_gemini_cli_skills_writes_skill_dirs(self, tmp_path):
+ skills_root = install_gemini_cli_skills(tmp_path)
+ assert skills_root == tmp_path / ".gemini" / "skills"
+ skill_path = skills_root / "explore-codebase" / "SKILL.md"
+ assert skill_path.exists()
+ text = skill_path.read_text(encoding="utf-8")
+ assert text.startswith("---\n")
+ assert "name: explore-codebase" in text
+ assert "description:" in text
+
+
class TestCursorHooksConfig:
"""Tests for generate_cursor_hooks_config()."""
diff --git a/uv.lock b/uv.lock
index 62a32ad..c40535b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -411,6 +411,7 @@ requires-dist = [
{ name = "pyyaml", marker = "extra == 'eval'", specifier = ">=6.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0,<1" },
{ name = "sentence-transformers", marker = "extra == 'embeddings'", specifier = ">=3.0.0,<4" },
+ { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0,<3" },
{ name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2.0" },
{ name = "tree-sitter", specifier = ">=0.23.0,<1" },
{ name = "tree-sitter-language-pack", specifier = ">=0.3.0,<1" },