Add host bootstrap and repair helpers
Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits. Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material. Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here. Validation: - uv lock - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
This commit is contained in:
parent
535efc6919
commit
899a6760c4
25 changed files with 1658 additions and 58 deletions
484
tests/test_host_helpers.py
Normal file
484
tests/test_host_helpers.py
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
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(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"))
|
||||
|
||||
|
||||
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 _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"]]
|
||||
Loading…
Add table
Add a link
Reference in a new issue