From 1be416942dbbeebb6dfd1c32e6474f36a282eb31 Mon Sep 17 00:00:00 2001 From: fdcp Date: Thu, 4 Jun 2026 08:29:58 +0000 Subject: [PATCH 1/5] fix(install): write opencode MCP config in official schema code-review-graph install --platform opencode wrote {repo_root}/.opencode.json with a Cursor-shaped mcpServers payload (command + args + type stdio). OpenCode's documented schema is different: project config lives in opencode.json, MCP servers go under the mcp key, local servers use type local and a single array-form command. The previous file was never loaded by OpenCode. - config_path -> opencode.json, key -> mcp, needs_type -> False - _build_server_entry emits McpLocalConfig (array command, type local) for opencode and nothing else - _warn_legacy_opencode_config prints a one-line hint when an old .opencode.json with a code-review-graph entry is still present (no auto-delete, safer than clobbering) - test_install_opencode_config asserts the new schema; two new tests cover the legacy-file warning (presence + absence) - docs/USAGE.md updated to reflect the new path Closes #388. --- code_review_graph/skills.py | 48 ++++++++++++++++++++++---- docs/USAGE.md | 2 +- tests/test_skills.py | 67 ++++++++++++++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index 8f72daf0..fe232df8 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -84,11 +84,15 @@ def _zed_settings_path() -> Path: }, "opencode": { "name": "OpenCode", - "config_path": lambda root: root / ".opencode.json", - "key": "mcpServers", + "config_path": lambda root: ( + root / "opencode.jsonc" if (root / "opencode.jsonc").exists() + else root / "opencode.json" if (root / "opencode.json").exists() + else root / "opencode.jsonc" + ), + "key": "mcp", "detect": lambda: True, "format": "object", - "needs_type": True, + "needs_type": False, }, "antigravity": { "name": "Antigravity", @@ -235,17 +239,45 @@ 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": + entry: dict[str, Any] = {"command": [command, *args], "type": "local"} + if repo_root is not None: + entry["cwd"] = str(repo_root) + return entry + 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 _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( + re.sub( + r"(^|\s)//.*$", + "", + legacy.read_text(encoding="utf-8", errors="replace"), + flags=re.MULTILINE, + ) + ) + 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 {repo_root / 'opencode.json'}." + ) + + def _format_toml_value(value: Any) -> str: """Format a primitive Python value as TOML.""" if isinstance(value, str): @@ -319,6 +351,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 +381,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 = re.sub(r'(^|\s)//.*$', '', raw, flags=re.MULTILINE) 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..3f19e59e 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.json` / `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..0e166f01 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -765,11 +765,68 @@ 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 entry["command"][-1] == "serve" + assert "args" not in entry + assert "env" not in entry + + 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 +917,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.""" From 8cdbc2a379b6892c7be764117dcba3d5b97534be Mon Sep 17 00:00:00 2001 From: fdcp <15667917+fdcp@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:47:34 +0000 Subject: [PATCH 2/5] fix(install): correct opencode config path resolution and JSONC comment stripping - _opencode_config_path helper: PLATFORMS["opencode"].config_path and _warn_legacy_opencode_config now share one source of truth, so the warning's "new config is at ..." no longer disagrees with the actual write path (previously the warning said opencode.json while the lambda defaulted to opencode.jsonc). - _strip_jsonc_comments helper: stateful scanner that respects quoted strings. The earlier regex r'(^|\s)//.*$' still ate "//" inside string values when preceded by whitespace (e.g. "text": "\thello // world"), so it is replaced in install_platform_configs and _warn_legacy_opencode_config. - Tests: 3 new in TestJsoncHelpers (string preservation, full-line and inline stripping, _opencode_config_path precedence). 1274 passed. --- code_review_graph/skills.py | 58 ++++++++++++++++++++++++++++--------- tests/test_skills.py | 32 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index fe232df8..ce543c7e 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -84,11 +84,7 @@ def _zed_settings_path() -> Path: }, "opencode": { "name": "OpenCode", - "config_path": lambda root: ( - root / "opencode.jsonc" if (root / "opencode.jsonc").exists() - else root / "opencode.json" if (root / "opencode.json").exists() - else root / "opencode.jsonc" - ), + "config_path": lambda root: _opencode_config_path(root), "key": "mcp", "detect": lambda: True, "format": "object", @@ -253,6 +249,47 @@ def _build_server_entry( 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] != "\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" @@ -260,12 +297,7 @@ def _warn_legacy_opencode_config(repo_root: Path) -> None: return try: data = json.loads( - re.sub( - r"(^|\s)//.*$", - "", - legacy.read_text(encoding="utf-8", errors="replace"), - flags=re.MULTILINE, - ) + _strip_jsonc_comments(legacy.read_text(encoding="utf-8", errors="replace")) ) except (json.JSONDecodeError, OSError): return @@ -274,7 +306,7 @@ def _warn_legacy_opencode_config(repo_root: Path) -> None: 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 {repo_root / 'opencode.json'}." + f"load. The new config is at {_opencode_config_path(repo_root)}." ) @@ -381,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'(^|\s)//.*$', '', 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/tests/test_skills.py b/tests/test_skills.py index 0e166f01..57beb02c 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -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, @@ -982,6 +984,36 @@ 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_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" From 60933ff407217e2e784d456f11049c99c888571c Mon Sep 17 00:00:00 2001 From: fdcp <15667917+fdcp@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:14:17 +0000 Subject: [PATCH 3/5] fix(install): handle CRLF/CR line endings in _strip_jsonc_comments; sync opencode docs - _strip_jsonc_comments now stops at either "\r" or "\n". The previous check (text[i] != "\n") silently consumed the "\r" in Windows CRLF line endings and would consume the entire file on old-Mac CR-only line endings. Found by Copilot re-review (thread on skills.py L286). - docs/USAGE.md: clarify the opencode config precedence (opencode.jsonc existing -> opencode.json existing -> else create opencode.jsonc) so the table matches _opencode_config_path. - Tests: 1 new in TestJsoncHelpers covering both \r\n and \r-only line endings. 1275 passed. --- code_review_graph/skills.py | 2 +- docs/USAGE.md | 2 +- tests/test_skills.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index ce543c7e..d9746aa1 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -282,7 +282,7 @@ def _strip_jsonc_comments(text: str) -> str: i += 1 continue if c == "/" and i + 1 < n and text[i + 1] == "/": - while i < n and text[i] != "\n": + while i < n and text[i] not in "\r\n": i += 1 continue out.append(c) diff --git a/docs/USAGE.md b/docs/USAGE.md index 3f19e59e..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.jsonc` | +| **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 57beb02c..02c668d8 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1004,6 +1004,10 @@ def test_strip_jsonc_comments_strips_full_line_and_inline_outside_strings(self): ) assert json.loads(_strip_jsonc_comments(raw)) == {"a": 1, "b": 2} + def test_strip_jsonc_comments_handles_crlf_and_cr_line_endings(self): + assert json.loads(_strip_jsonc_comments('// tail\r\n{"a": 1, "b": 2}\r\n')) == {"a": 1, "b": 2} + assert json.loads(_strip_jsonc_comments('// tail\r{"a": 1, "b": 2}\r')) == {"a": 1, "b": 2} + def test_opencode_config_path_prefers_jsonc_then_json_then_defaults_to_jsonc( self, tmp_path, ): From b7ec0efdba29d93cc3db60d372c0891fb2c7d55b Mon Sep 17 00:00:00 2001 From: fdcp Date: Fri, 12 Jun 2026 02:51:57 +0000 Subject: [PATCH 4/5] fix(install): drop cwd from OpenCode entry to match McpLocalConfig spec McpLocalConfig in opencode's schema is additionalProperties: false (only type/command/environment/enabled/timeout). Pass --repo to serve via the command array instead, which feeds _default_repo_root in main.py. Also fix E501 in test_skills.py:913 (103 > 100 chars). --- code_review_graph/skills.py | 6 +++--- tests/test_skills.py | 10 +++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/code_review_graph/skills.py b/code_review_graph/skills.py index d9746aa1..40f05c49 100644 --- a/code_review_graph/skills.py +++ b/code_review_graph/skills.py @@ -236,10 +236,10 @@ def _build_server_entry( """Build the MCP server entry for a platform.""" command, args = _detect_serve_command() if key == "opencode": - entry: dict[str, Any] = {"command": [command, *args], "type": "local"} + cmd: list[str] = [command, *args] if repo_root is not None: - entry["cwd"] = str(repo_root) - return entry + 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: diff --git a/tests/test_skills.py b/tests/test_skills.py index 02c668d8..4a8be7bc 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -772,9 +772,11 @@ def test_install_opencode_config(self, tmp_path): entry = data["mcp"]["code-review-graph"] assert entry["type"] == "local" assert isinstance(entry["command"], list) - assert entry["command"][-1] == "serve" + 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" @@ -1005,8 +1007,10 @@ def test_strip_jsonc_comments_strips_full_line_and_inline_outside_strings(self): assert json.loads(_strip_jsonc_comments(raw)) == {"a": 1, "b": 2} def test_strip_jsonc_comments_handles_crlf_and_cr_line_endings(self): - assert json.loads(_strip_jsonc_comments('// tail\r\n{"a": 1, "b": 2}\r\n')) == {"a": 1, "b": 2} - assert json.loads(_strip_jsonc_comments('// tail\r{"a": 1, "b": 2}\r')) == {"a": 1, "b": 2} + 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, From 55db06ec29a8bd0c2f2288cee3c5d82b44f730df Mon Sep 17 00:00:00 2001 From: fdcp Date: Fri, 12 Jun 2026 03:15:46 +0000 Subject: [PATCH 5/5] fix(tests): clean up pre-existing lint in test_skills Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- tests/test_skills.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_skills.py b/tests/test_skills.py index 4a8be7bc..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 @@ -32,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, @@ -1469,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"