From 20542c600ac9c09c0d6aa13dd990894211ba0171 Mon Sep 17 00:00:00 2001 From: tzhouam Date: Tue, 25 Aug 2026 13:15:16 +0800 Subject: [PATCH] installer: first-class ZCode target (MCP config merge + skills) Mirror of the Cursor path, per the #83 spec: auto-detect (`zcode` on PATH or ~/.zcode/ present) or explicit --agent zcode; non-destructive merge of mcp.servers.infermatrix-copilot into ~/.zcode/cli/config.json with a timestamped .bak (that file also holds hooks/plugin state, so unrelated keys are preserved and malformed JSON refuses the merge leaving the original untouched); skills copied to user-scope ~/.zcode/skills/; restart hint covers the /imreview slash form. Tests cover: unrelated-key preservation + .bak, fresh-config creation, malformed-JSON refusal (no .bak, no skills), non-object mcp.servers refusal, and detection by CLI or config dir. Closes #83. Closes #99 (duplicate request). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0165QsXAeLVPrKfj8Yk5zns3 --- scripts/install_mcp.py | 56 ++++++++++++++++++- test/test_install_mcp_script.py | 98 +++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/scripts/install_mcp.py b/scripts/install_mcp.py index 19485e28..cd33bccc 100644 --- a/scripts/install_mcp.py +++ b/scripts/install_mcp.py @@ -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 @@ -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 @@ -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( @@ -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: @@ -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 , " + "Claude/Cursor/ZCode: restart, then run: /imreview , " "/imcifix , or /imupdate " ) return 0 diff --git a/test/test_install_mcp_script.py b/test/test_install_mcp_script.py index f63a0e0d..dfd5398d 100644 --- a/test/test_install_mcp_script.py +++ b/test/test_install_mcp_script.py @@ -2,6 +2,8 @@ import json from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] INSTALLER = ROOT / "scripts" / "install_mcp.py" @@ -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 ):