Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -511,5 +512,5 @@ MIT. See [LICENSE](LICENSE).
<br>
<a href="https://code-review-graph.com">code-review-graph.com</a><br><br>
<code>pip install code-review-graph && code-review-graph install</code><br>
<sub>Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Qwen, Qoder, and Kiro</sub>
<sub>Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, and Kiro</sub>
</p>
30 changes: 23 additions & 7 deletions code_review_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]


Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
160 changes: 159 additions & 1 deletion code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -720,14 +728,164 @@ 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",),
".kiro/steering/code-review-graph.md": ("kiro",),
}


# --- 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>/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.

Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down
65 changes: 65 additions & 0 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()."""

Expand Down
1 change: 1 addition & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.