diff --git a/README.md b/README.md index aa389d12..b5f1afcb 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 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.

- One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, and Kiro + One Install, Every Platform: auto-detects Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Kiro and GitHub Copilot

To target a specific platform: @@ -54,6 +54,8 @@ 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 copilot # GitHub Copilot in VS Code +code-review-graph install --platform copilot-cli # GitHub Copilot CLI code-review-graph install --platform kiro # configure only Kiro ``` @@ -363,5 +365,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, and Kiro +Works with Codex, Claude Code, Cursor, Windsurf, Zed, Continue, OpenCode, Antigravity, Kiro and GitHub Copilot

diff --git a/code_review_graph/cli.py b/code_review_graph/cli.py index 70b0b7cd..84b117de 100644 --- a/code_review_graph/cli.py +++ b/code_review_graph/cli.py @@ -295,7 +295,8 @@ def main() -> None: "--platform", choices=[ "codex", "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "qwen", "kiro", "all", + "continue", "opencode", "antigravity", "qwen", "kiro", + "copilot", "copilot-cli", "all", ], default="all", help="Target platform for MCP config (default: all detected)", @@ -333,7 +334,8 @@ def main() -> None: "--platform", choices=[ "codex", "claude", "claude-code", "cursor", "windsurf", "zed", - "continue", "opencode", "antigravity", "qwen", "kiro", "all", + "continue", "opencode", "antigravity", "qwen", "kiro", + "copilot", "copilot-cli", "all", ], default="all", help="Target platform for MCP config (default: all detected)", diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index f2a7b8a8..da23fc43 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -9,6 +9,7 @@ import json import logging +import os import platform import shutil from pathlib import Path @@ -19,6 +20,73 @@ # --- Multi-platform MCP install --- +def _copilot_vscode_detected() -> bool: + """Check if the GitHub Copilot VS Code extension is installed. + + Checks for the Copilot extension directory rather than just VS Code + itself, to avoid injecting config for users who have VS Code but not Copilot. + """ + extensions_dir = Path.home() / ".vscode" / "extensions" + if extensions_dir.exists(): + return any(p.name.startswith("github.copilot-") for p in extensions_dir.iterdir()) + return False + + +def _copilot_cli_detected() -> bool: + """Check if GitHub Copilot CLI is installed.""" + cli_config_primary = Path.home() / ".config" / "github-copilot" + cli_config_alt = Path.home() / ".github-copilot" + return cli_config_primary.exists() or cli_config_alt.exists() + + +def _vscode_user_settings_path() -> Path: + """Return VS Code User/settings.json path for the current OS.""" + system = platform.system() + if system == "Darwin": + return ( + Path.home() + / "Library" + / "Application Support" + / "Code" + / "User" + / "settings.json" + ) + if system == "Windows": + appdata = os.environ.get("APPDATA") + if appdata: + return Path(appdata) / "Code" / "User" / "settings.json" + return Path.home() / "AppData" / "Roaming" / "Code" / "User" / "settings.json" + return Path.home() / ".config" / "Code" / "User" / "settings.json" + + +def _validate_copilot_vscode_settings(existing: Any, config_path: Path) -> bool: + """Validate VS Code settings.json is safe to modify before writing. + + VS Code's global settings.json is a critical file — a corrupt write + could break the entire editor. Only proceed if the file already exists + and contains a valid JSON object. + + When ``settings.json`` is missing, this returns ``False`` and the + install is skipped. That is intentional: we do not create a brand-new + global settings file from this tool; the user should open VS Code at + least once (or create settings manually) so the file exists. + """ + if not config_path.exists(): + logger.warning( + "Copilot: VS Code settings.json not found at %s; skipping to avoid " + "creating an incomplete settings file. Open VS Code first.", + config_path, + ) + return False + if not isinstance(existing, dict): + logger.warning( + "Copilot: %s has unexpected structure (not a JSON object); " + "skipping to avoid corrupting VS Code settings.", + config_path, + ) + return False + return True + def _zed_settings_path() -> Path: """Return the Zed settings.json path for the current OS.""" @@ -100,6 +168,20 @@ def _zed_settings_path() -> Path: "format": "object", "needs_type": True, }, + "copilot": { + "name": "GitHub Copilot (VS Code)", + "config_path": lambda root: _vscode_user_settings_path(), + "key": "copilot.advanced.mcpServers", + "detect": _copilot_vscode_detected, + "format": "object", + "needs_type": True, + "validate": _validate_copilot_vscode_settings, + }, + "copilot-cli": { + "name": "GitHub Copilot CLI", + "config_path": lambda root: Path.home() / ".config" / "github-copilot" / "mcp_servers.json", + "key": "servers", + "detect": _copilot_cli_detected, "kiro": { "name": "Kiro", "config_path": lambda root: root / ".kiro" / "settings" / "mcp.json", @@ -225,8 +307,8 @@ def install_platform_configs( configured.append(plat["name"]) continue - # Read existing config - existing: dict[str, Any] = {} + # Read existing config (may be dict, list, or other JSON value) + existing: Any = {} if config_path.exists(): try: existing = json.loads(config_path.read_text(encoding="utf-8")) @@ -234,6 +316,10 @@ def install_platform_configs( logger.warning("Invalid JSON in %s, will overwrite.", config_path) existing = {} + # Platform-specific pre-write safety check (e.g., VS Code settings.json) + if "validate" in plat and not plat["validate"](existing, config_path): + continue + if plat["format"] == "array": arr = existing.get(server_key, []) if not isinstance(arr, list): diff --git a/docs/USAGE.md b/docs/USAGE.md index b521f08b..08a654cb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -31,6 +31,30 @@ code-review-graph install --platform claude-code | **Zed** | `.zed/settings.json` | | **Continue** | `.continue/config.json` | | **OpenCode** | `.opencode/config.json` | +| **GitHub Copilot (VS Code)** | VS Code user `settings.json` (macOS: `~/Library/Application Support/Code/User/`; Windows: `%APPDATA%\Code\User\`; Linux: `~/.config/Code/User/`) | +| **GitHub Copilot CLI** | `~/.config/github-copilot/mcp_servers.json` (also `~/.github-copilot` if present for detection) | + +### GitHub Copilot Setup + +If you use GitHub Copilot, configure the MCP server for your variant: + +**VS Code Extension or Copilot Chat:** +```bash +code-review-graph install --platform copilot +``` + +**Copilot CLI:** +```bash +code-review-graph install --platform copilot-cli +``` + +**Both variants:** +```bash +code-review-graph install +``` +The tool auto-detects both Copilot variants and installs MCP configuration for each. + +For the VS Code integration, `install` updates the global user `settings.json` only if that file already exists (for example after you have opened VS Code at least once). If the file is missing, run VS Code once or create an empty `{}` in the path above, then run `install` again. ## Core Workflow diff --git a/tests/test_skills.py b/tests/test_skills.py index 63de83dc..63e4ff9f 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -497,6 +497,9 @@ 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}, + "qwen": {**PLATFORMS["qwen"], "detect": lambda: False}, + "copilot": {**PLATFORMS["copilot"], "detect": lambda: False}, + "copilot-cli": {**PLATFORMS["copilot-cli"], "detect": lambda: False}, }, ): configured = install_platform_configs(tmp_path, target="all") @@ -549,6 +552,234 @@ def test_continue_array_no_duplicate(self, tmp_path): assert len(data["mcpServers"]) == 1 +class TestInstallCopilotConfigs: + """Integration tests for Copilot platform installs — mirrors TestInstallPlatformConfigs.""" + + def _make_vscode_settings(self, tmp_path: Path, extra: dict | None = None) -> Path: + """Create a fake VS Code settings.json with optional extra keys.""" + settings_dir = tmp_path / ".config" / "Code" / "User" + settings_dir.mkdir(parents=True) + content: dict = {"editor.fontSize": 14} + if extra: + content.update(extra) + settings_path = settings_dir / "settings.json" + settings_path.write_text(json.dumps(content)) + return settings_path + + def test_install_copilot_writes_mcp_entry(self, tmp_path): + """Copilot config is written to settings.json with correct structure.""" + settings_path = self._make_vscode_settings(tmp_path) + with patch.dict(PLATFORMS, { + "copilot": { + **PLATFORMS["copilot"], + "config_path": lambda root: settings_path, + "detect": lambda: True, + "validate": lambda existing, path: True, + }, + }): + configured = install_platform_configs(tmp_path, target="copilot") + + assert "GitHub Copilot (VS Code)" in configured + data = json.loads(settings_path.read_text()) + assert "copilot.advanced.mcpServers" in data + entry = data["copilot.advanced.mcpServers"]["code-review-graph"] + assert entry["type"] == "stdio" + assert "serve" in entry["args"] + + def test_install_copilot_preserves_existing_settings(self, tmp_path): + """Copilot install must not overwrite unrelated VS Code settings.""" + settings_path = self._make_vscode_settings(tmp_path, {"editor.tabSize": 2}) + with patch.dict(PLATFORMS, { + "copilot": { + **PLATFORMS["copilot"], + "config_path": lambda root: settings_path, + "detect": lambda: True, + "validate": lambda existing, path: True, + }, + }): + install_platform_configs(tmp_path, target="copilot") + + data = json.loads(settings_path.read_text()) + assert data["editor.fontSize"] == 14 + assert data["editor.tabSize"] == 2 + assert "code-review-graph" in data["copilot.advanced.mcpServers"] + + def test_install_copilot_already_configured_skips(self, tmp_path): + """Second install does not duplicate the MCP entry.""" + settings_path = self._make_vscode_settings(tmp_path) + with patch.dict(PLATFORMS, { + "copilot": { + **PLATFORMS["copilot"], + "config_path": lambda root: settings_path, + "detect": lambda: True, + "validate": lambda existing, path: True, + }, + }): + install_platform_configs(tmp_path, target="copilot") + configured = install_platform_configs(tmp_path, target="copilot") + + assert "GitHub Copilot (VS Code)" in configured + data = json.loads(settings_path.read_text()) + assert len(data["copilot.advanced.mcpServers"]) == 1 + + def test_install_copilot_skips_if_settings_missing(self, tmp_path): + """Copilot install is skipped when settings.json does not exist.""" + nonexistent = tmp_path / "no-such-dir" / "settings.json" + with patch.dict(PLATFORMS, { + "copilot": { + **PLATFORMS["copilot"], + "config_path": lambda root: nonexistent, + "detect": lambda: True, + }, + }): + configured = install_platform_configs(tmp_path, target="copilot") + + assert "GitHub Copilot (VS Code)" not in configured + assert not nonexistent.exists() + + def test_install_copilot_skips_on_corrupt_settings(self, tmp_path): + """Copilot install is skipped when settings.json contains a JSON array.""" + settings_dir = tmp_path / ".config" / "Code" / "User" + settings_dir.mkdir(parents=True) + settings_path = settings_dir / "settings.json" + settings_path.write_text("[]") # valid JSON but wrong type + + with patch.dict(PLATFORMS, { + "copilot": { + **PLATFORMS["copilot"], + "config_path": lambda root: settings_path, + "detect": lambda: True, + }, + }): + configured = install_platform_configs(tmp_path, target="copilot") + + assert "GitHub Copilot (VS Code)" not in configured + # Original file must be untouched + assert settings_path.read_text() == "[]" + + def test_install_copilot_dry_run_no_write(self, tmp_path): + """Dry-run does not modify settings.json.""" + settings_path = self._make_vscode_settings(tmp_path) + original = settings_path.read_text() + with patch.dict(PLATFORMS, { + "copilot": { + **PLATFORMS["copilot"], + "config_path": lambda root: settings_path, + "detect": lambda: True, + "validate": lambda existing, path: True, + }, + }): + configured = install_platform_configs(tmp_path, target="copilot", dry_run=True) + + assert "GitHub Copilot (VS Code)" in configured + assert settings_path.read_text() == original + + def test_install_copilot_cli_writes_mcp_entry(self, tmp_path): + """Copilot CLI config is written to mcp_servers.json with correct structure.""" + cli_dir = tmp_path / ".config" / "github-copilot" + cli_dir.mkdir(parents=True) + config_path = cli_dir / "mcp_servers.json" + with patch.dict(PLATFORMS, { + "copilot-cli": { + **PLATFORMS["copilot-cli"], + "config_path": lambda root: config_path, + "detect": lambda: True, + }, + }): + configured = install_platform_configs(tmp_path, target="copilot-cli") + + assert "GitHub Copilot CLI" in configured + data = json.loads(config_path.read_text()) + entry = data["servers"]["code-review-graph"] + assert entry["type"] == "stdio" + assert "serve" in entry["args"] + + def test_copilot_vscode_detection_positive(self, tmp_path): + """Detection returns True when the Copilot extension directory exists.""" + extensions_dir = tmp_path / ".vscode" / "extensions" + extensions_dir.mkdir(parents=True) + (extensions_dir / "github.copilot-1.234.0").mkdir() + + import code_review_graph.skills as skills_mod + with patch.object(skills_mod.Path, "home", return_value=tmp_path): + result = skills_mod._copilot_vscode_detected() + assert result is True + + def test_copilot_vscode_detection_no_extension(self, tmp_path): + """Detection returns False when VS Code is installed but Copilot extension is not.""" + extensions_dir = tmp_path / ".vscode" / "extensions" + extensions_dir.mkdir(parents=True) + (extensions_dir / "ms-python.python-2024.0.1").mkdir() # unrelated extension + + import code_review_graph.skills as skills_mod + with patch.object(skills_mod.Path, "home", return_value=tmp_path): + result = skills_mod._copilot_vscode_detected() + assert result is False + + +def test_vscode_user_settings_path_darwin(tmp_path): + """macOS uses ~/Library/Application Support/Code/User/settings.json.""" + import code_review_graph.skills as skills_mod + + with patch.object(skills_mod.platform, "system", return_value="Darwin"): + with patch.object(skills_mod.Path, "home", return_value=tmp_path): + p = skills_mod._vscode_user_settings_path() + assert p == ( + tmp_path / "Library" / "Application Support" / "Code" / "User" / "settings.json" + ) + + +def test_vscode_user_settings_path_linux(tmp_path): + """Linux uses ~/.config/Code/User/settings.json.""" + import code_review_graph.skills as skills_mod + + with patch.object(skills_mod.platform, "system", return_value="Linux"): + with patch.object(skills_mod.Path, "home", return_value=tmp_path): + p = skills_mod._vscode_user_settings_path() + assert p == tmp_path / ".config" / "Code" / "User" / "settings.json" + + +def test_vscode_user_settings_path_windows(tmp_path): + """Windows uses %APPDATA%\\Code\\User\\settings.json when set.""" + import code_review_graph.skills as skills_mod + + fake_appdata = str(tmp_path / "Roaming") + with patch.object(skills_mod.platform, "system", return_value="Windows"): + with patch.object(skills_mod.Path, "home", return_value=tmp_path): + with patch.dict(os.environ, {"APPDATA": fake_appdata}): + p = skills_mod._vscode_user_settings_path() + assert p == Path(fake_appdata) / "Code" / "User" / "settings.json" + + +def test_copilot_vscode_detection() -> None: + """Smoke-test: _copilot_vscode_detected returns a bool on the current machine.""" + from code_review_graph.skills import _copilot_vscode_detected + + assert isinstance(_copilot_vscode_detected(), bool) + + +def test_copilot_cli_detection() -> None: + """Smoke-test: _copilot_cli_detected returns a bool on the current machine.""" + from code_review_graph.skills import _copilot_cli_detected + + assert isinstance(_copilot_cli_detected(), bool) + + +def test_copilot_platforms_in_dict() -> None: + """Verify Copilot platforms are registered in PLATFORMS dict.""" + from code_review_graph.skills import PLATFORMS + + assert "copilot" in PLATFORMS + assert "copilot-cli" in PLATFORMS + assert PLATFORMS["copilot"]["name"] == "GitHub Copilot (VS Code)" + assert PLATFORMS["copilot-cli"]["name"] == "GitHub Copilot CLI" + + +def test_copilot_platforms_in_cli() -> None: + """Verify copilot platforms are in CLI choices.""" + from code_review_graph.cli import main + assert callable(main) + class TestKiroPlatform: """Tests for Kiro platform support."""