Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ code-review-graph build # parse your codebase
One command sets up everything. `install` detects which AI coding tools you have, writes the correct MCP configuration for each one, installs platform-native hooks/skills where supported, and injects graph-aware instructions into your platform rules. It auto-detects whether you installed via `uvx` or `pip`/`pipx` and generates the right config. Restart your editor/tool after installing.

<p align="center">
<img src="diagrams/diagram8_supported_platforms.png" alt="One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, Kiro, and GitHub Copilot" width="85%" />
<img src="diagrams/diagram8_supported_platforms.png" alt="One Install, Every Platform: auto-detects Codex, Claude Code, CodeBuddy Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, Kiro, and GitHub Copilot" width="85%" />
</p>

To target a specific platform:
Expand All @@ -68,6 +68,7 @@ code-review-graph install --platform gemini-cli # configure only Gemini CLI
code-review-graph install --platform kiro # configure only Kiro
code-review-graph install --platform copilot # configure only GitHub Copilot (VS Code)
code-review-graph install --platform copilot-cli # configure only GitHub Copilot CLI
code-review-graph install --platform codebuddy # configure only CodeBuddy Code
```

Requires Python 3.10+. For the best experience, install [uv](https://docs.astral.sh/uv/) (the MCP config will use `uvx` if available, otherwise falls back to the `code-review-graph` command directly).
Expand Down Expand Up @@ -646,5 +647,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, Gemini CLI, Qwen, Qoder, Kiro, GitHub Copilot, and GitHub Copilot CLI</sub>
<sub>Works with Codex, Claude Code, CodeBuddy Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Gemini CLI, Qwen, Qoder, Kiro, GitHub Copilot, and GitHub Copilot CLI</sub>
</p>
15 changes: 14 additions & 1 deletion code_review_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
_PLATFORM_CHOICES = [
"codex", "claude", "claude-code", "cursor", "windsurf", "zed",
"continue", "opencode", "antigravity", "gemini-cli", "qwen", "kiro", "qoder",
"copilot", "copilot-cli", "all",
"copilot", "copilot-cli", "codebuddy", "all",
]


Expand Down Expand Up @@ -243,6 +243,8 @@ def _handle_init(args: argparse.Namespace) -> None:
generate_skills,
inject_claude_md,
inject_platform_instructions,
install_codebuddy_hooks,
install_codebuddy_skills,
install_codex_hooks,
install_cursor_hooks,
install_gemini_cli_hooks,
Expand All @@ -264,6 +266,11 @@ def _handle_init(args: argparse.Namespace) -> None:
gemini_skills_dir = install_gemini_cli_skills(repo_root)
print(f"Installed Gemini CLI skills in {gemini_skills_dir}")

# CodeBuddy discovers project skills under .codebuddy/skills/.
if target in ("codebuddy", "all"):
codebuddy_skills_dir = install_codebuddy_skills(repo_root)
print(f"Installed CodeBuddy skills in {codebuddy_skills_dir}")

# Confirm before writing instruction files (#173). --yes skips the
# prompt; --no-instructions skips the whole block.
if not skip_instructions and instr_targets:
Expand All @@ -290,6 +297,12 @@ def _handle_init(args: argparse.Namespace) -> None:
qoder_skills_dir = install_qoder_skills(repo_root)
if qoder_skills_dir:
print(f"Installed Qoder skills to {qoder_skills_dir}")
if not skip_hooks and target in ("codebuddy", "all"):
try:
codebuddy_settings = install_codebuddy_hooks(repo_root)
print(f"Installed CodeBuddy hooks in {codebuddy_settings}")
except Exception as exc:
logger.warning("Could not install CodeBuddy hooks: %s", exc)
if not skip_hooks and target in ("codex", "all"):
hooks_path = install_codex_hooks(repo_root)
print(f"Installed Codex hooks in {hooks_path}")
Expand Down
124 changes: 103 additions & 21 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@ def _opencode_config_path(repo_root: Path) -> Path:
"format": "object",
"needs_type": True,
},
"codebuddy": {
"name": "CodeBuddy Code",
"config_path": lambda root: root / ".mcp.json",
"key": "mcpServers",
"detect": lambda: True,
"format": "object",
"needs_type": True,
},
}


Expand Down Expand Up @@ -416,11 +424,30 @@ def install_platform_configs(
Returns:
List of platform names that were configured.
"""
shared_aliases: dict[str, tuple[str, ...]] = {}
if target == "all":
platforms_to_install = {k: v for k, v in PLATFORMS.items() if v["detect"]()}
# Workspace-level Kiro detection
if "kiro" not in platforms_to_install and (repo_root / ".kiro").is_dir():
platforms_to_install["kiro"] = PLATFORMS["kiro"]

# Claude Code and CodeBuddy intentionally share the official project
# .mcp.json/mcpServers contract. Process that exact pair once, but do
# not deduplicate arbitrary clients merely because a config path
# happens to match: their keys or entry schemas may differ.
claude = platforms_to_install.get("claude")
codebuddy = platforms_to_install.get("codebuddy")
if claude is not None and codebuddy is not None:
same_contract = (
claude["config_path"](repo_root) == codebuddy["config_path"](repo_root)
and all(
claude[field] == codebuddy[field]
for field in ("key", "format", "needs_type")
)
)
if same_contract:
shared_aliases["claude"] = ("codebuddy",)
del platforms_to_install["codebuddy"]
else:
if target not in PLATFORMS:
logger.error("Unknown platform: %s", target)
Expand All @@ -429,6 +456,10 @@ def install_platform_configs(

configured: list[str] = []

def _record_configured(key: str, plat: dict[str, Any]) -> None:
configured.append(plat["name"])
configured.extend(PLATFORMS[alias]["name"] for alias in shared_aliases.get(key, ()))

for key, plat in platforms_to_install.items():
if key == "opencode":
_warn_legacy_opencode_config(repo_root)
Expand All @@ -445,13 +476,13 @@ def install_platform_configs(
)
if not changed:
print(f" {plat['name']}: already configured in {config_path}")
configured.append(plat["name"])
_record_configured(key, plat)
continue
if dry_run:
print(f" [dry-run] {plat['name']}: would write {config_path}")
else:
print(f" {plat['name']}: configured {config_path}")
configured.append(plat["name"])
_record_configured(key, plat)
continue

# Read existing config
Expand Down Expand Up @@ -505,7 +536,7 @@ def install_platform_configs(
# Check if already present
if any(isinstance(s, dict) and s.get("name") == "code-review-graph" for s in arr):
print(f" {plat['name']}: already configured in {config_path}")
configured.append(plat["name"])
_record_configured(key, plat)
continue
arr_entry = {"name": "code-review-graph", **server_entry}
arr.append(arr_entry)
Expand All @@ -514,7 +545,7 @@ def install_platform_configs(
servers = existing.get(server_key, {})
if "code-review-graph" in servers:
print(f" {plat['name']}: already configured in {config_path}")
configured.append(plat["name"])
_record_configured(key, plat)
continue
servers["code-review-graph"] = server_entry
existing[server_key] = servers
Expand All @@ -526,7 +557,7 @@ def install_platform_configs(
config_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
print(f" {plat['name']}: configured {config_path}")

configured.append(plat["name"])
_record_configured(key, plat)

return configured

Expand Down Expand Up @@ -855,21 +886,11 @@ def install_git_hook(repo_root: Path) -> Path | None:
return hook_path


def install_hooks(repo_root: Path, platform: str = "claude") -> None:
"""Write hooks config to platform-specific settings.json.

Merges new hook entries into existing settings, preserving both
non-hook configuration and user-defined hooks. A backup of the
original file is created before any modifications.

Args:
repo_root: Repository root directory.
platform: Target platform ("claude" or "qoder").
"""
if platform == "qoder":
settings_dir = repo_root / ".qoder"
else:
settings_dir = repo_root / ".claude"
def _merge_hooks_into_settings(
settings_dir: Path,
hooks_config: dict[str, Any],
) -> Path:
"""Merge hook entries into a project settings file without clobbering users."""
settings_dir.mkdir(parents=True, exist_ok=True)
settings_path = settings_dir / "settings.json"

Expand All @@ -883,7 +904,6 @@ def install_hooks(repo_root: Path, platform: str = "claude") -> None:
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Could not read existing %s: %s", settings_path, exc)

hooks_config = generate_hooks_config(repo_root)
existing_hooks = existing.get("hooks", {})
if not isinstance(existing_hooks, dict):
logger.warning("Existing hooks config is not a dict; replacing with defaults")
Expand All @@ -904,6 +924,44 @@ def install_hooks(repo_root: Path, platform: str = "claude") -> None:

settings_path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
logger.info("Wrote hooks config: %s", settings_path)
return settings_path


def install_hooks(repo_root: Path, platform: str = "claude") -> None:
"""Write hooks config to platform-specific settings.json.

Merges new hook entries into existing settings, preserving both
non-hook configuration and user-defined hooks. A backup of the
original file is created before any modifications.

Args:
repo_root: Repository root directory.
platform: Target platform ("claude" or "qoder").
"""
if platform == "qoder":
settings_dir = repo_root / ".qoder"
else:
settings_dir = repo_root / ".claude"
_merge_hooks_into_settings(settings_dir, generate_hooks_config(repo_root))


def install_codebuddy_hooks(repo_root: Path) -> Path:
"""Install runtime-resolved POSIX hooks in .codebuddy/settings.json.

CodeBuddy uses the same nested hook schema and POSIX-shell execution
model as the existing project hooks. The shared generator deliberately
resolves the checkout at hook runtime instead of embedding the installer's
absolute path, so committed settings work for every collaborator.
"""
hooks_config = generate_hooks_config(repo_root)
# CodeBuddy's Bash tool can create or rewrite files without going through
# Edit/Write, so its PostToolUse contract also observes Bash. The command
# itself still resolves the repository dynamically at hook runtime.
hooks_config["hooks"]["PostToolUse"][0]["matcher"] = "Edit|Write|Bash"
return _merge_hooks_into_settings(
repo_root / ".codebuddy",
hooks_config,
)


def install_codex_hooks(repo_root: Path) -> Path:
Expand Down Expand Up @@ -1105,6 +1163,7 @@ def inject_claude_md(repo_root: Path) -> None:
"QODER.md": ("qoder",),
".kiro/steering/code-review-graph.md": ("kiro",),
".github/code-review-graph.instruction.md": ("copilot", "copilot-cli"),
"CODEBUDDY.md": ("codebuddy",),
}


Expand Down Expand Up @@ -1265,6 +1324,29 @@ def install_gemini_cli_skills(repo_root: Path) -> Path:
return skills_root


def install_codebuddy_skills(repo_root: Path) -> Path:
"""Install project skills in .codebuddy/skills/<name>/SKILL.md."""
skills_root = repo_root / ".codebuddy" / "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 CodeBuddy 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
9 changes: 9 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ To target a specific platform instead of auto-detecting all:
code-review-graph install --platform codex
code-review-graph install --platform cursor
code-review-graph install --platform claude-code
code-review-graph install --platform codebuddy
```

### Supported Platforms
Expand All @@ -26,6 +27,7 @@ code-review-graph install --platform claude-code
|----------|-------------|
| **Codex** | `~/.codex/config.toml` + `~/.codex/hooks.json` |
| **Claude Code** | `.mcp.json` + `.claude/settings.json` |
| **CodeBuddy Code** | `.mcp.json` + `CODEBUDDY.md` + `.codebuddy/settings.json` + `.codebuddy/skills/<name>/SKILL.md` |
| **Cursor** | `.cursor/mcp.json` |
| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` |
| **Zed** | `.zed/settings.json` |
Expand All @@ -39,6 +41,13 @@ code-review-graph install --platform claude-code
| **GitHub Copilot** | `.vscode/mcp.json` |
| **GitHub Copilot CLI** | `~/.copilot/mcp-config.json` |

The CodeBuddy project layout follows its official documentation for
[MCP configuration](https://www.codebuddy.ai/docs/cli/mcp),
[skills](https://www.codebuddy.ai/docs/cli/skills), and
[hooks](https://www.codebuddy.ai/docs/cli/hooks). The shared `.mcp.json` is
merged with JSONC awareness, while hook commands resolve the repository at
runtime so committed settings do not contain one developer's checkout path.

## Core Workflow

### 1. Build the graph (first time only)
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
│ │
│ MCP clients Hooks / watch mode │
│ ├── Codex └── incremental update │
│ ├── Claude Code
│ ├── Claude Code, CodeBuddy Code
│ ├── Cursor, Windsurf, Zed, Continue │
│ └── Gemini CLI, Qwen, Qoder, Copilot, OpenCode │
│ │ │ │
Expand Down
Loading