Skip to content
Merged
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
56 changes: 54 additions & 2 deletions scripts/install_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,55 @@ def _install_cursor(config_root: Path) -> None:
_install_skills(cursor_root / "skills")


def _install_zcode(config_root: Path) -> None:
zcode_root = config_root / ".zcode"
config_path = zcode_root / "cli" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)

if config_path.exists():
try:
config = json.loads(config_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise InstallError(
f"ZCode config is invalid and was not changed: {config_path}"
) from exc
if not isinstance(config, dict):
raise InstallError(f"ZCode config must be an object: {config_path}")
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(
config_path,
config_path.with_name(f"{config_path.name}.{timestamp}.bak"),
)
else:
config = {}

# ZCode nests MCP servers under `mcp.servers` (that file also carries
# hooks/plugin state, so only this one server entry is touched).
mcp = config.setdefault("mcp", {})
if not isinstance(mcp, dict):
raise InstallError(f"ZCode mcp section must be an object: {config_path}")
servers = mcp.setdefault("servers", {})
if not isinstance(servers, dict):
raise InstallError(
f"ZCode mcp.servers must be an object: {config_path}"
)
servers[SERVER_NAME] = {
"type": "stdio",
"command": SERVER_COMMAND[0],
"args": SERVER_COMMAND[1:],
}
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# User-scope skills take precedence over ~/.agents/skills.
_install_skills(zcode_root / "skills")


def _zcode_installed(config_root: Path) -> bool:
return bool(shutil.which("zcode")) or (config_root / ".zcode").exists()


def _cursor_installed(config_root: Path) -> bool:
if shutil.which("cursor") or shutil.which("cursor-agent"):
return True
Expand All @@ -311,6 +360,8 @@ def _detect_agents(config_root: Path) -> list[str]:
agents.append("claude")
if _cursor_installed(config_root):
agents.append("cursor")
if _zcode_installed(config_root):
agents.append("zcode")
return agents


Expand Down Expand Up @@ -339,7 +390,7 @@ def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
parser.add_argument(
"--agent",
action="append",
choices=("codex", "claude", "cursor"),
choices=("codex", "claude", "cursor", "zcode"),
help="Override automatic Agent detection. May be repeated.",
)
parser.add_argument(
Expand Down Expand Up @@ -392,6 +443,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
"codex": _install_codex,
"claude": _install_claude,
"cursor": _install_cursor,
"zcode": _install_zcode,
}
for agent in agents:
try:
Expand All @@ -415,7 +467,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
)
if any(agent != "codex" for agent in agents):
print(
"Claude/Cursor: restart, then run: /imreview <PR URL>, "
"Claude/Cursor/ZCode: restart, then run: /imreview <PR URL>, "
"/imcifix <issue URL>, or /imupdate <repository>"
)
return 0
Expand Down
98 changes: 98 additions & 0 deletions test/test_install_mcp_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import json
from pathlib import Path

import pytest


ROOT = Path(__file__).resolve().parents[1]
INSTALLER = ROOT / "scripts" / "install_mcp.py"
Expand Down Expand Up @@ -103,6 +105,102 @@ def test_cursor_install_preserves_existing_config(tmp_path):
assert str(ROOT) in text


def test_zcode_install_preserves_existing_config(tmp_path):
installer = _load_installer()
config_path = tmp_path / ".zcode" / "cli" / "config.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps(
{
"hooks": {"kept": True},
"mcp": {
"other": "kept",
"servers": {"existing": {"command": "existing"}},
},
}
),
encoding="utf-8",
)

installer._install_zcode(tmp_path)

config = json.loads(config_path.read_text(encoding="utf-8"))
assert config["hooks"] == {"kept": True}
assert config["mcp"]["other"] == "kept"
assert config["mcp"]["servers"]["existing"]["command"] == "existing"
server = config["mcp"]["servers"]["infermatrix-copilot"]
assert server["type"] == "stdio"
assert server["command"] == "uvx"
assert "infermatrix-copilot-mcp" in server["args"]
assert list(config_path.parent.glob("config.json.*.bak"))
skills_root = tmp_path / ".zcode" / "skills"
assert (skills_root / "imreview" / "SKILL.md").is_file()
assert (skills_root / "imdesign" / "SKILL.md").is_file()
update_skill = skills_root / "imupdate" / "SKILL.md"
assert update_skill.is_file()
text = update_skill.read_text(encoding="utf-8")
assert "{{INFERMATRIX_COPILOT_ROOT}}" not in text
assert str(ROOT) in text


def test_zcode_install_creates_config_when_absent(tmp_path):
installer = _load_installer()

installer._install_zcode(tmp_path)

config_path = tmp_path / ".zcode" / "cli" / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
assert config["mcp"]["servers"]["infermatrix-copilot"]["command"] == "uvx"
assert not list(config_path.parent.glob("config.json.*.bak"))


def test_zcode_install_refuses_malformed_config(tmp_path):
installer = _load_installer()
config_path = tmp_path / ".zcode" / "cli" / "config.json"
config_path.parent.mkdir(parents=True)
config_path.write_text("{not json", encoding="utf-8")

with pytest.raises(installer.InstallError):
installer._install_zcode(tmp_path)

assert config_path.read_text(encoding="utf-8") == "{not json"
assert not list(config_path.parent.glob("config.json.*.bak"))
assert not (tmp_path / ".zcode" / "skills").exists()


def test_zcode_install_refuses_non_object_servers(tmp_path):
installer = _load_installer()
config_path = tmp_path / ".zcode" / "cli" / "config.json"
config_path.parent.mkdir(parents=True)
config_path.write_text(
json.dumps({"mcp": {"servers": ["not", "a", "dict"]}}),
encoding="utf-8",
)

with pytest.raises(installer.InstallError):
installer._install_zcode(tmp_path)

config = json.loads(config_path.read_text(encoding="utf-8"))
assert config["mcp"]["servers"] == ["not", "a", "dict"]


def test_zcode_detected_by_config_dir(monkeypatch, tmp_path):
installer = _load_installer()
monkeypatch.setattr(installer.shutil, "which", lambda command: None)
(tmp_path / ".zcode").mkdir()

assert installer._zcode_installed(tmp_path)
assert "zcode" in installer._detect_agents(tmp_path)


def test_zcode_not_detected_without_cli_or_dir(monkeypatch, tmp_path):
installer = _load_installer()
monkeypatch.setattr(installer.shutil, "which", lambda command: None)

assert not installer._zcode_installed(tmp_path)
assert "zcode" not in installer._detect_agents(tmp_path)


def test_codex_install_sets_timeout_and_installs_current_skill_location(
monkeypatch, tmp_path
):
Expand Down
Loading