Skip to content
Open
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
80 changes: 73 additions & 7 deletions code_review_graph/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Comment thread
fdcp marked this conversation as resolved.
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"]:
Comment thread
fdcp marked this conversation as resolved.
Comment thread
fdcp marked this conversation as resolved.
Comment thread
fdcp marked this conversation as resolved.
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)}."
)
Comment thread
fdcp marked this conversation as resolved.


def _format_toml_value(value: Any) -> str:
"""Format a primitive Python value as TOML."""
if isinstance(value, str):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
112 changes: 104 additions & 8 deletions tests/test_skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,17 +22,19 @@
_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,
generate_skills,
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,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down