diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 8f72daf0..40f05c49 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -84,11 +84,11 @@ def _zed_settings_path() -> Path: }, "opencode": { "name": "OpenCode", - "config_path": lambda root: root / ".opencode.json", - "key": "mcpServers", + "config_path": lambda root: _opencode_config_path(root), + "key": "mcp", "detect": lambda: True, "format": "object", - "needs_type": True, + "needs_type": False, }, "antigravity": { "name": "Antigravity", @@ -235,17 +235,81 @@ def _build_server_entry( ) -> dict[str, Any]: """Build the MCP server entry for a platform.""" command, args = _detect_serve_command() - entry: dict[str, Any] = {"command": command, "args": args} + if key == "opencode": + cmd: list[str] = [command, *args] + if repo_root is not None: + cmd += ["--repo", str(repo_root)] + return {"command": cmd, "type": "local"} + entry = {"command": command, "args": args} # Include cwd so the MCP server can find the graph database if repo_root is not None: entry["cwd"] = str(repo_root) if plat["needs_type"]: entry["type"] = "stdio" - if key == "opencode": - entry["env"] = [] return entry +def _opencode_config_path(root: Path) -> Path: + """Pick the project-level OpenCode config file to read or write.""" + for name in ("opencode.jsonc", "opencode.json"): + candidate = root / name + if candidate.is_file(): + return candidate + return root / "opencode.jsonc" + + +def _strip_jsonc_comments(text: str) -> str: + """Remove ``//`` line comments from JSONC text, respecting quoted strings.""" + out: list[str] = [] + i, n = 0, len(text) + in_string = False + while i < n: + c = text[i] + if in_string: + if c == "\\" and i + 1 < n: + out.append(c) + out.append(text[i + 1]) + i += 2 + continue + out.append(c) + if c == '"': + in_string = False + i += 1 + continue + if c == '"': + in_string = True + out.append(c) + i += 1 + continue + if c == "/" and i + 1 < n and text[i + 1] == "/": + while i < n and text[i] not in "\r\n": + i += 1 + continue + out.append(c) + i += 1 + return "".join(out) + + +def _warn_legacy_opencode_config(repo_root: Path) -> None: + """Warn when a legacy ``.opencode.json`` (Cursor-shaped) is still present.""" + legacy = repo_root / ".opencode.json" + if not legacy.is_file(): + return + try: + data = json.loads( + _strip_jsonc_comments(legacy.read_text(encoding="utf-8", errors="replace")) + ) + except (json.JSONDecodeError, OSError): + return + if isinstance(data, dict) and isinstance(data.get("mcpServers"), dict) \ + and "code-review-graph" in data["mcpServers"]: + print( + f" Note: removing/replacing {legacy} is recommended — it was written " + f"by an older code-review-graph and uses a schema OpenCode does not " + f"load. The new config is at {_opencode_config_path(repo_root)}." + ) + + def _format_toml_value(value: Any) -> str: """Format a primitive Python value as TOML.""" if isinstance(value, str): @@ -319,6 +383,8 @@ def install_platform_configs( configured: list[str] = [] for key, plat in platforms_to_install.items(): + if key == "opencode": + _warn_legacy_opencode_config(repo_root) config_path: Path = plat["config_path"](repo_root) server_key = plat["key"] server_entry = _build_server_entry(plat, key=key, repo_root=repo_root) @@ -347,7 +413,7 @@ def install_platform_configs( raw = config_path.read_text(encoding="utf-8", errors="replace") # Strip single-line comments and trailing commas (JSONC compat # for editors like Zed that allow non-standard JSON). - stripped = re.sub(r'//.*?$', '', raw, flags=re.MULTILINE) + stripped = _strip_jsonc_comments(raw) stripped = re.sub(r',(\s*[}\]])', r'\1', stripped) try: existing = json.loads(stripped) diff --git a/docs/USAGE.md b/docs/USAGE.md index 14b6c403..b1f10524 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -30,7 +30,7 @@ code-review-graph install --platform claude-code | **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | | **Zed** | `.zed/settings.json` | | **Continue** | `.continue/config.json` | -| **OpenCode** | `.opencode.json` | +| **OpenCode** | `opencode.jsonc` (existing) → `opencode.json` (else create `opencode.jsonc`) | | **Antigravity** | `~/.gemini/antigravity/mcp_config.json` | | **Gemini CLI** | `.gemini/settings.json` | | **Qwen Code** | `~/.qwen/settings.json` | diff --git a/tests/test_skills.py b/tests/test_skills.py index 2914ca79..5add9262 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -2,8 +2,8 @@ import json import os -import subprocess import stat +import subprocess import sys from pathlib import Path from unittest.mock import patch @@ -22,7 +22,9 @@ _detect_serve_command, _in_poetry_project, _in_uv_project, + _opencode_config_path, _opencode_plugin_content, + _strip_jsonc_comments, generate_codex_hooks_config, generate_cursor_hooks_config, generate_hooks_config, @@ -30,9 +32,9 @@ inject_claude_md, inject_platform_instructions, install_codex_hooks, + install_cursor_hooks, install_gemini_cli_hooks, install_gemini_cli_skills, - install_cursor_hooks, install_git_hook, install_hooks, install_opencode_plugin, @@ -765,11 +767,70 @@ def test_install_continue_config(self, tmp_path): def test_install_opencode_config(self, tmp_path): configured = install_platform_configs(tmp_path, target="opencode") assert "OpenCode" in configured - config_path = tmp_path / ".opencode.json" + config_path = tmp_path / "opencode.jsonc" data = json.loads(config_path.read_text()) - entry = data["mcpServers"]["code-review-graph"] - assert entry["type"] == "stdio" - assert entry["env"] == [] + entry = data["mcp"]["code-review-graph"] + assert entry["type"] == "local" + assert isinstance(entry["command"], list) + assert "serve" in entry["command"] + assert "args" not in entry + assert "env" not in entry + assert "cwd" not in entry + assert str(tmp_path) in entry["command"] + + def test_install_opencode_warns_on_legacy_dotfile(self, tmp_path, capsys): + legacy = tmp_path / ".opencode.json" + legacy.write_text( + json.dumps( + { + "mcpServers": { + "code-review-graph": { + "command": "old", + "args": ["old"], + "type": "stdio", + } + } + } + ) + ) + install_platform_configs(tmp_path, target="opencode") + captured = capsys.readouterr() + assert "removing/replacing" in captured.out + assert str(legacy) in captured.out + assert (tmp_path / "opencode.jsonc").exists() + assert legacy.exists() + + def test_install_opencode_no_warning_when_no_legacy(self, tmp_path, capsys): + install_platform_configs(tmp_path, target="opencode") + captured = capsys.readouterr() + assert "removing/replacing" not in captured.out + + def test_install_opencode_merges_into_existing_jsonc(self, tmp_path): + existing = tmp_path / "opencode.jsonc" + existing.write_text( + json.dumps( + { + "$schema": "https://opencode.ai/config.json", + "mcp": {"other-server": {"type": "local", "command": ["x"]}}, + } + ) + ) + install_platform_configs(tmp_path, target="opencode") + assert existing.exists() + assert not (tmp_path / "opencode.json").exists() + data = json.loads(existing.read_text()) + assert "other-server" in data["mcp"] + assert "code-review-graph" in data["mcp"] + + def test_install_opencode_merges_into_existing_json(self, tmp_path): + existing = tmp_path / "opencode.json" + existing.write_text(json.dumps({"mcp": {"other": {"type": "local"}}})) + install_platform_configs(tmp_path, target="opencode") + assert existing.exists() + assert not (tmp_path / "opencode.jsonc").exists() + data = json.loads(existing.read_text()) + assert "other" in data["mcp"] + assert "code-review-graph" in data["mcp"] def test_install_gemini_cli_config(self, tmp_path): gemini_config = tmp_path / ".gemini" / "settings.json" @@ -860,7 +921,7 @@ def test_install_all_detected(self, tmp_path): assert "OpenCode" in configured assert codex_config.exists() assert (tmp_path / ".mcp.json").exists() - assert (tmp_path / ".opencode.json").exists() + assert (tmp_path / "opencode.jsonc").exists() def test_merge_existing_servers(self, tmp_path): """Should not overwrite existing MCP servers.""" @@ -925,6 +986,42 @@ def test_install_qoder_config(self, tmp_path): assert data["mcpServers"]["code-review-graph"]["command"] == expected_cmd +class TestJsoncHelpers: + def test_strip_jsonc_comments_preserves_slashes_in_strings(self): + raw = ( + '{"$schema": "https://opencode.ai/config.json", ' + '"x": "a // b", "y": "hi\\"//escaped"}' + ) + assert json.loads(_strip_jsonc_comments(raw)) == { + "$schema": "https://opencode.ai/config.json", + "x": "a // b", + "y": 'hi"//escaped', + } + + def test_strip_jsonc_comments_strips_full_line_and_inline_outside_strings(self): + raw = ( + '// header comment\n' + '{"a": 1, "b": 2} // inline tail\n' + '// {"ignored": true}\n' + ) + assert json.loads(_strip_jsonc_comments(raw)) == {"a": 1, "b": 2} + + def test_strip_jsonc_comments_handles_crlf_and_cr_line_endings(self): + crlf_raw = '// tail\r\n{"a": 1, "b": 2}\r\n' + cr_raw = '// tail\r{"a": 1, "b": 2}\r' + assert json.loads(_strip_jsonc_comments(crlf_raw)) == {"a": 1, "b": 2} + assert json.loads(_strip_jsonc_comments(cr_raw)) == {"a": 1, "b": 2} + + def test_opencode_config_path_prefers_jsonc_then_json_then_defaults_to_jsonc( + self, tmp_path, + ): + assert _opencode_config_path(tmp_path) == tmp_path / "opencode.jsonc" + (tmp_path / "opencode.json").write_text("{}") + assert _opencode_config_path(tmp_path) == tmp_path / "opencode.json" + (tmp_path / "opencode.jsonc").write_text("{}") + assert _opencode_config_path(tmp_path) == tmp_path / "opencode.jsonc" + + class TestGeminiCLIInstall: def test_install_gemini_cli_hooks_creates_settings_and_scripts(self, tmp_path): settings_dir = tmp_path / ".gemini" @@ -1372,7 +1469,6 @@ def test_install_copilot_cli_preserves_existing_servers(self, tmp_path): assert "code-review-graph" in data["servers"] def test_copilot_cli_writes_only_copilot_instructions(self, tmp_path): - """inject_platform_instructions with target='copilot-cli' writes .github/code-review-graph.instruction.md.""" updated = inject_platform_instructions(tmp_path, target="copilot-cli") assert ".github/code-review-graph.instruction.md" in updated instructions = tmp_path / ".github" / "code-review-graph.instruction.md"