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
236 lines
7.8 KiB
Python
236 lines
7.8 KiB
Python
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import pyro_mcp.project_startup as project_startup
|
|
from pyro_mcp.project_startup import (
|
|
ProjectStartupSource,
|
|
describe_project_startup_source,
|
|
materialize_project_startup_source,
|
|
resolve_project_startup_source,
|
|
)
|
|
|
|
|
|
def _git(repo: Path, *args: str) -> str:
|
|
result = subprocess.run( # noqa: S603
|
|
["git", "-c", "commit.gpgsign=false", *args],
|
|
cwd=repo,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path:
|
|
root.mkdir()
|
|
_git(root, "init")
|
|
_git(root, "config", "user.name", "Pyro Tests")
|
|
_git(root, "config", "user.email", "pyro-tests@example.com")
|
|
(root / filename).write_text(content, encoding="utf-8")
|
|
_git(root, "add", filename)
|
|
_git(root, "commit", "-m", "init")
|
|
return root
|
|
|
|
|
|
def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path / "repo")
|
|
nested = repo / "src" / "pkg"
|
|
nested.mkdir(parents=True)
|
|
|
|
resolved = resolve_project_startup_source(cwd=nested)
|
|
|
|
assert resolved == ProjectStartupSource(
|
|
kind="project_path",
|
|
origin_ref=str(repo.resolve()),
|
|
resolved_path=repo.resolve(),
|
|
)
|
|
|
|
|
|
def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path / "repo")
|
|
nested = repo / "nested"
|
|
nested.mkdir()
|
|
|
|
resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path)
|
|
|
|
assert resolved == ProjectStartupSource(
|
|
kind="project_path",
|
|
origin_ref=str(repo.resolve()),
|
|
resolved_path=repo.resolve(),
|
|
)
|
|
|
|
|
|
def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path / "repo")
|
|
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git")
|
|
|
|
with pytest.raises(ValueError, match="requires --repo-url"):
|
|
resolve_project_startup_source(repo_ref="main")
|
|
|
|
with pytest.raises(ValueError, match="cannot be combined"):
|
|
resolve_project_startup_source(project_path=repo, no_project_source=True)
|
|
|
|
|
|
def test_resolve_project_startup_source_handles_explicit_none_and_empty_values(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
repo = _make_repo(tmp_path / "repo")
|
|
outside = tmp_path / "outside"
|
|
outside.mkdir()
|
|
|
|
assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None
|
|
assert resolve_project_startup_source(cwd=outside) is None
|
|
|
|
with pytest.raises(ValueError, match="must not be empty"):
|
|
resolve_project_startup_source(repo_url=" ", cwd=repo)
|
|
|
|
with pytest.raises(ValueError, match="must not be empty"):
|
|
resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ")
|
|
|
|
|
|
def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
missing = tmp_path / "missing"
|
|
file_path = tmp_path / "note.txt"
|
|
file_path.write_text("hello\n", encoding="utf-8")
|
|
|
|
with pytest.raises(ValueError, match="does not exist"):
|
|
resolve_project_startup_source(project_path=missing, cwd=tmp_path)
|
|
|
|
with pytest.raises(ValueError, match="must be a directory"):
|
|
resolve_project_startup_source(project_path=file_path, cwd=tmp_path)
|
|
|
|
|
|
def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
plain = tmp_path / "plain"
|
|
plain.mkdir()
|
|
|
|
resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path)
|
|
|
|
assert resolved == ProjectStartupSource(
|
|
kind="project_path",
|
|
origin_ref=str(plain.resolve()),
|
|
resolved_path=plain.resolve(),
|
|
)
|
|
|
|
|
|
def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path / "repo", content="one\n")
|
|
first_commit = _git(repo, "rev-parse", "HEAD")
|
|
(repo / "note.txt").write_text("two\n", encoding="utf-8")
|
|
_git(repo, "add", "note.txt")
|
|
_git(repo, "commit", "-m", "update")
|
|
|
|
source = ProjectStartupSource(
|
|
kind="repo_url",
|
|
origin_ref=str(repo.resolve()),
|
|
repo_ref=first_commit,
|
|
)
|
|
|
|
with materialize_project_startup_source(source) as clone_dir:
|
|
assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n"
|
|
|
|
|
|
def test_materialize_project_startup_source_validates_project_source_and_clone_failures(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
with pytest.raises(RuntimeError, match="missing a resolved path"):
|
|
with materialize_project_startup_source(
|
|
ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None)
|
|
):
|
|
pass
|
|
|
|
source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
|
|
|
|
def _clone_failure(
|
|
command: list[str],
|
|
*,
|
|
cwd: Path | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
del cwd
|
|
return subprocess.CompletedProcess(command, 1, "", "clone failed")
|
|
|
|
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure)
|
|
with pytest.raises(RuntimeError, match="failed to clone repo_url"):
|
|
with materialize_project_startup_source(source):
|
|
pass
|
|
|
|
|
|
def test_materialize_project_startup_source_reports_checkout_failure(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
repo = _make_repo(tmp_path / "repo", content="one\n")
|
|
source = ProjectStartupSource(
|
|
kind="repo_url",
|
|
origin_ref=str(repo.resolve()),
|
|
repo_ref="missing-ref",
|
|
)
|
|
|
|
original_run_git = project_startup._run_git
|
|
|
|
def _checkout_failure(
|
|
command: list[str],
|
|
*,
|
|
cwd: Path | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
if command[:2] == ["git", "checkout"]:
|
|
return subprocess.CompletedProcess(command, 1, "", "checkout failed")
|
|
return original_run_git(command, cwd=cwd)
|
|
|
|
monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure)
|
|
with pytest.raises(RuntimeError, match="failed to checkout repo_ref"):
|
|
with materialize_project_startup_source(source):
|
|
pass
|
|
|
|
|
|
def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
|
|
repo = _make_repo(tmp_path / "repo")
|
|
|
|
project_description = describe_project_startup_source(
|
|
ProjectStartupSource(
|
|
kind="project_path",
|
|
origin_ref=str(repo.resolve()),
|
|
resolved_path=repo.resolve(),
|
|
)
|
|
)
|
|
repo_description = describe_project_startup_source(
|
|
ProjectStartupSource(
|
|
kind="repo_url",
|
|
origin_ref="https://example.com/repo.git",
|
|
repo_ref="main",
|
|
)
|
|
)
|
|
|
|
assert project_description == f"the current project at {repo.resolve()}"
|
|
assert repo_description == "the clean clone source https://example.com/repo.git at ref main"
|
|
|
|
|
|
def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None:
|
|
assert describe_project_startup_source(None) is None
|
|
assert (
|
|
describe_project_startup_source(
|
|
ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git")
|
|
)
|
|
== "the clean clone source https://example.com/repo.git"
|
|
)
|
|
|
|
|
|
def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
project_startup,
|
|
"_run_git",
|
|
lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""),
|
|
)
|
|
|
|
assert project_startup._detect_git_root(Path.cwd()) is None
|