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" },