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.
-
+
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."""