from __future__ import annotations import json import shutil import sys from pathlib import Path from subprocess import CompletedProcess import pytest import pyro_mcp.host_helpers as host_helpers from pyro_mcp.host_helpers import ( DEFAULT_OPENCODE_CONFIG_PATH, HostServerConfig, _canonical_server_command, _command_matches, _repair_command, connect_cli_host, doctor_hosts, print_or_write_opencode_config, repair_host, ) def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]: bin_dir = tmp_path / "bin" bin_dir.mkdir(parents=True) state_path = tmp_path / f"{name}-state.json" script_path = bin_dir / name script_path.write_text( "\n".join( [ f"#!{sys.executable}", "import json", "import shlex", "import sys", f"STATE_PATH = {str(state_path)!r}", "try:", " with open(STATE_PATH, 'r', encoding='utf-8') as handle:", " state = json.load(handle)", "except FileNotFoundError:", " state = {}", "args = sys.argv[1:]", "if args[:2] == ['mcp', 'add']:", " name = args[2]", " marker = args.index('--')", " state[name] = args[marker + 1:]", " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", " json.dump(state, handle)", " print(f'added {name}')", " raise SystemExit(0)", "if args[:2] == ['mcp', 'remove']:", " name = args[2]", " if name in state:", " del state[name]", " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", " json.dump(state, handle)", " print(f'removed {name}')", " raise SystemExit(0)", " print('not found', file=sys.stderr)", " raise SystemExit(1)", "if args[:2] == ['mcp', 'get']:", " name = args[2]", " if name not in state:", " print('not found', file=sys.stderr)", " raise SystemExit(1)", " print(f'{name}: {shlex.join(state[name])}')", " raise SystemExit(0)", "if args[:2] == ['mcp', 'list']:", " for item in sorted(state):", " print(item)", " raise SystemExit(0)", "print('unsupported', file=sys.stderr)", "raise SystemExit(2)", ] ), encoding="utf-8", ) script_path.chmod(0o755) return bin_dir, state_path def test_connect_cli_host_replaces_existing_entry( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex") state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8") monkeypatch.setenv("PATH", str(bin_dir)) payload = connect_cli_host("codex", config=HostServerConfig()) assert payload["host"] == "codex" assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] assert json.loads(state_path.read_text(encoding="utf-8")) == { "pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] } def test_canonical_server_command_validates_and_renders_variants() -> None: assert _canonical_server_command(HostServerConfig(installed_package=True)) == [ "pyro", "mcp", "serve", ] assert _canonical_server_command( HostServerConfig(profile="workspace-full", project_path="/repo") ) == [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-full", "--project-path", "/repo", ] assert _canonical_server_command( HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main") ) == [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--repo-url", "https://example.com/repo.git", "--repo-ref", "main", ] assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix", ] assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--no-project-source", ] with pytest.raises(ValueError, match="mutually exclusive"): _canonical_server_command( HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git") ) with pytest.raises(ValueError, match="cannot be combined"): _canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True)) with pytest.raises(ValueError, match="requires --repo-url"): _canonical_server_command(HostServerConfig(repo_ref="main")) with pytest.raises(ValueError, match="mutually exclusive"): _canonical_server_command( HostServerConfig(profile="workspace-full", mode="repro-fix") ) def test_repair_command_and_command_matches_cover_edge_cases() -> None: assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex" assert _repair_command("codex", HostServerConfig(project_path="/repo")) == ( "pyro host repair codex --project-path /repo" ) assert _repair_command( "opencode", HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"), config_path=Path("/tmp/opencode.json"), ) == ( "pyro host repair opencode --installed-package --profile workspace-full " "--repo-url file:///repo --config-path /tmp/opencode.json" ) assert _repair_command("codex", HostServerConfig(no_project_source=True)) == ( "pyro host repair codex --no-project-source" ) assert _repair_command("codex", HostServerConfig(mode="inspect")) == ( "pyro host repair codex --mode inspect" ) assert _command_matches( "pyro: uvx --from pyro-mcp pyro mcp serve", ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], ) assert _command_matches( '"uvx --from pyro-mcp pyro mcp serve', ['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"], ) assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", ]) def test_connect_cli_host_reports_missing_cli_and_add_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: with pytest.raises(ValueError, match="unsupported CLI host"): connect_cli_host("unsupported", config=HostServerConfig()) monkeypatch.setenv("PATH", "") with pytest.raises(RuntimeError, match="codex CLI is not installed"): connect_cli_host("codex", config=HostServerConfig()) bin_dir = tmp_path / "bin" bin_dir.mkdir() script_path = bin_dir / "codex" script_path.write_text( "\n".join( [ f"#!{sys.executable}", "import sys", "raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)", ] ), encoding="utf-8", ) script_path.chmod(0o755) monkeypatch.setenv("PATH", str(bin_dir)) with pytest.raises(RuntimeError, match="codex mcp add failed"): connect_cli_host("codex", config=HostServerConfig()) def test_doctor_hosts_reports_ok_and_drifted( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex") claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude") combined_path = str(codex_bin) + ":" + str(claude_bin) monkeypatch.setenv("PATH", combined_path) codex_state.write_text( json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}), encoding="utf-8", ) claude_state.write_text( json.dumps( { "pyro": [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "workspace-full", ] } ), encoding="utf-8", ) opencode_config = tmp_path / "opencode.json" opencode_config.write_text( json.dumps( { "mcp": { "pyro": { "type": "local", "enabled": True, "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], } } } ), encoding="utf-8", ) entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config) by_host = {entry.host: entry for entry in entries} assert by_host["codex"].status == "ok" assert by_host["codex"].configured is True assert by_host["claude-code"].status == "drifted" assert by_host["claude-code"].configured is True assert by_host["opencode"].status == "ok" assert by_host["opencode"].configured is True def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("PATH", "") config_path = tmp_path / "opencode.json" config_path.write_text("[]", encoding="utf-8") entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) by_host = {entry.host: entry for entry in entries} assert by_host["opencode"].status == "unavailable" assert "JSON object" in by_host["opencode"].details config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8") entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) by_host = {entry.host: entry for entry in entries} assert by_host["opencode"].status == "unavailable" assert "missing mcp.pyro" in by_host["opencode"].details config_path.write_text( json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}), encoding="utf-8", ) entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) by_host = {entry.host: entry for entry in entries} assert by_host["opencode"].status == "drifted" assert by_host["opencode"].configured is True def test_doctor_hosts_reports_invalid_json_for_installed_opencode( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: config_path = tmp_path / "opencode.json" config_path.write_text("{invalid", encoding="utf-8") monkeypatch.setattr( shutil, "which", lambda name: "/usr/bin/opencode" if name == "opencode" else None, ) entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) by_host = {entry.host: entry for entry in entries} assert by_host["opencode"].status == "drifted" assert "invalid JSON" in by_host["opencode"].details def test_repair_opencode_preserves_unrelated_keys( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("PATH", "") config_path = tmp_path / "opencode.json" config_path.write_text( json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}), encoding="utf-8", ) payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) assert payload["config_path"] == str(config_path.resolve()) repaired = json.loads(config_path.read_text(encoding="utf-8")) assert repaired["theme"] == "light" assert repaired["mcp"]["other"] == {"type": "local"} assert repaired["mcp"]["pyro"] == { "type": "local", "enabled": True, "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], } def test_repair_opencode_backs_up_non_object_json( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("PATH", "") config_path = tmp_path / "opencode.json" config_path.write_text("[]", encoding="utf-8") payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) backup_path = Path(str(payload["backup_path"])) assert backup_path.exists() assert backup_path.read_text(encoding="utf-8") == "[]" def test_repair_opencode_backs_up_invalid_json( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("PATH", "") config_path = tmp_path / "opencode.json" config_path.write_text("{invalid", encoding="utf-8") payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) backup_path = Path(str(payload["backup_path"])) assert backup_path.exists() assert backup_path.read_text(encoding="utf-8") == "{invalid" repaired = json.loads(config_path.read_text(encoding="utf-8")) assert repaired["mcp"]["pyro"]["command"] == [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", ] def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None: output_path = tmp_path / "opencode.json" payload = print_or_write_opencode_config( config=HostServerConfig(project_path="/repo"), output_path=output_path, ) assert payload["output_path"] == str(output_path) rendered = json.loads(output_path.read_text(encoding="utf-8")) assert rendered == { "mcp": { "pyro": { "type": "local", "enabled": True, "command": [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--project-path", "/repo", ], } } } def test_print_or_write_opencode_config_returns_rendered_text() -> None: payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run")) assert payload["host"] == "opencode" assert payload["server_command"] == [ "uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--profile", "vm-run", ] rendered = str(payload["rendered_config"]) assert '"type": "local"' in rendered assert '"command": [' in rendered def test_doctor_reports_opencode_missing_when_config_absent( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, ) -> None: monkeypatch.setenv("PATH", "") entries = doctor_hosts( config=HostServerConfig(), config_path=tmp_path / "missing-opencode.json", ) by_host = {entry.host: entry for entry in entries} assert by_host["opencode"].status == "unavailable" assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( host_helpers, "connect_cli_host", lambda host, *, config: {"host": host, "profile": config.profile}, ) assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == { "host": "codex", "profile": "vm-run", } commands: list[list[str]] = [] def _fake_run_command(command: list[str]) -> CompletedProcess[str]: commands.append(command) if command[:3] == ["codex", "mcp", "get"]: return CompletedProcess(command, 1, "", "not found") if command[:3] == ["codex", "mcp", "list"]: return CompletedProcess(command, 0, "pyro\n", "") raise AssertionError(command) monkeypatch.setattr( shutil, "which", lambda name: "/usr/bin/codex" if name == "codex" else None, ) monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command) entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig()) assert entry.status == "drifted" assert entry.configured is True assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]