From 899a6760c45d2eaac989889dbcbd673291803007 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 16:46:10 -0300 Subject: [PATCH] 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 --- CHANGELOG.md | 12 + Makefile | 5 +- README.md | 38 +- docs/first-run.md | 21 +- docs/install.md | 29 +- docs/integrations.md | 46 ++ docs/public-contract.md | 11 + docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.2.0-host-bootstrap-and-repair.md | 2 +- examples/claude_code_mcp.md | 14 + examples/codex_mcp.md | 14 + examples/mcp_client_config.md | 8 + pyproject.toml | 2 +- src/pyro_mcp/cli.py | 284 +++++++++- src/pyro_mcp/contract.py | 15 +- src/pyro_mcp/host_helpers.py | 363 +++++++++++++ src/pyro_mcp/vm_environments.py | 4 +- tests/test_api.py | 2 +- tests/test_cli.py | 151 +++++- tests/test_host_helpers.py | 484 ++++++++++++++++++ tests/test_project_startup.py | 123 ++++- tests/test_public_contract.py | 28 + tests/test_server.py | 2 +- tests/test_workspace_ports.py | 48 +- uv.lock | 2 +- 25 files changed, 1658 insertions(+), 58 deletions(-) create mode 100644 src/pyro_mcp/host_helpers.py create mode 100644 tests/test_host_helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70987e6..d30ab17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.2.0 + +- Added host bootstrap and repair helpers with `pyro host connect`, + `pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the + supported Claude Code, Codex, and OpenCode flows. +- Repositioned the docs and examples so supported hosts now start from the + helper flow first, while keeping raw `pyro mcp serve` commands as the + underlying MCP entrypoint and advanced fallback. +- Added deterministic host-helper coverage so the shipped helper commands and + OpenCode config snippet stay aligned with the canonical `pyro mcp serve` + command shape. + ## 4.1.0 - Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can diff --git a/Makefile b/Makefile index 1211c8f..46ebd93 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ PYTHON ?= uv run python UV_CACHE_DIR ?= .uv-cache -PYTEST_FLAGS ?= -n auto +PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi') +PYTEST_FLAGS ?= -n $(PYTEST_WORKERS) OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_MODEL ?= llama3.2:3b OLLAMA_DEMO_FLAGS ?= @@ -84,6 +85,8 @@ check: lint typecheck test dist-check: uv run pyro --version uv run pyro --help >/dev/null + uv run pyro host --help >/dev/null + uv run pyro host doctor >/dev/null uv run pyro mcp --help >/dev/null uv run pyro run --help >/dev/null uv run pyro env list >/dev/null diff --git a/README.md b/README.md index 848fc29..8ab2054 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.1.0: [CHANGELOG.md#410](CHANGELOG.md#410) +- What's new in 4.2.0: [CHANGELOG.md#420](CHANGELOG.md#420) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -76,7 +76,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.1.0 +Catalog version: 4.2.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -96,9 +96,26 @@ for the guest image. ## Chat Host Quickstart After the quickstart works, the intended next step is to connect a chat host. -From a repo root, bare `pyro mcp serve` starts `workspace-core`, auto-detects -the current Git checkout, and lets the first `workspace_create` omit -`seed_path`. +Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect claude-code +uvx --from pyro-mcp pyro host connect codex +uvx --from pyro-mcp pyro host print-config opencode +``` + +If setup drifts or you want to inspect it first: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + +Those helpers wrap the same `pyro mcp serve` entrypoint. From a repo root, +bare `pyro mcp serve` starts `workspace-core`, auto-detects the current Git +checkout, and lets the first `workspace_create` omit `seed_path`. ```bash uvx --from pyro-mcp pyro mcp serve @@ -107,12 +124,14 @@ uvx --from pyro-mcp pyro mcp serve If the host does not preserve the server working directory, use: ```bash +uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo ``` If you are starting outside a local checkout, use a clean clone source: ```bash +uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git ``` @@ -149,7 +168,8 @@ OpenCode `opencode.json` snippet: } ``` -If OpenCode launches the server from an unexpected cwd, add +If OpenCode launches the server from an unexpected cwd, use +`pyro host print-config opencode --project-path /abs/path/to/repo` or add `"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command array. @@ -163,9 +183,9 @@ snapshots, secrets, network policy, or disk tools. 1. Validate the host with `pyro doctor`. 2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. -3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo - root, or use `--project-path` / `--repo-url` when cwd is not the source of - truth. +3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or + `pyro host print-config opencode`, then fall back to raw `pyro mcp serve` + with `--project-path` / `--repo-url` when cwd is not the source of truth. 4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). `repro-fix-loop` is the shortest chat-first story. 5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path diff --git a/docs/first-run.md b/docs/first-run.md index 6b40344..6fa3442 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.1.0 +Catalog version: 4.2.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -93,9 +93,27 @@ $ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/proje ## 6. Connect a chat host +Use the helper flow first: + +```bash +$ uvx --from pyro-mcp pyro host connect claude-code +$ uvx --from pyro-mcp pyro host connect codex +$ uvx --from pyro-mcp pyro host print-config opencode +``` + +If setup drifts later: + +```bash +$ uvx --from pyro-mcp pyro host doctor +$ uvx --from pyro-mcp pyro host repair claude-code +$ uvx --from pyro-mcp pyro host repair codex +$ uvx --from pyro-mcp pyro host repair opencode +``` + Claude Code: ```bash +$ uvx --from pyro-mcp pyro host connect claude-code $ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve $ claude mcp list ``` @@ -103,6 +121,7 @@ $ claude mcp list Codex: ```bash +$ uvx --from pyro-mcp pyro host connect codex $ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve $ codex mcp list ``` diff --git a/docs/install.md b/docs/install.md index cb02312..1879146 100644 --- a/docs/install.md +++ b/docs/install.md @@ -62,7 +62,8 @@ pyro run debian:12 -- git --version If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. -After that one-shot proof works, the intended next step is `pyro mcp serve`. +After that one-shot proof works, the intended next step is `pyro host connect` +or `pyro host print-config`. ## 1. Check the host @@ -92,7 +93,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.1.0 +Catalog version: 4.2.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -140,6 +141,23 @@ deterministic structured result. ## 5. Connect a chat host +Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect claude-code +uvx --from pyro-mcp pyro host connect codex +uvx --from pyro-mcp pyro host print-config opencode +``` + +If setup drifts later, inspect and repair it with: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also auto-detects the current Git checkout so the first `workspace_create` can omit `seed_path`. @@ -170,12 +188,14 @@ Copy-paste host-specific starts: Claude Code: ```bash +pyro host connect claude-code claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` Codex: ```bash +pyro host connect codex codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` @@ -195,8 +215,9 @@ The intended user journey is: 1. validate the host with `pyro doctor` 2. pull `debian:12` 3. prove guest execution with `pyro run debian:12 -- git --version` -4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo - root, or use `--project-path` / `--repo-url` when needed +4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or + `pyro host print-config opencode`, then use raw `pyro mcp serve` only when + you need `--project-path` / `--repo-url` 5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) 6. trust but verify with `make smoke-use-cases` diff --git a/docs/integrations.md b/docs/integrations.md index 7c0a96b..933a5ea 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -38,8 +38,35 @@ pyro mcp serve --repo-url https://github.com/example/project.git Use `--profile workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools. +## Helper First + +Use the helper flow before the raw host CLI commands: + +```bash +pyro host connect claude-code +pyro host connect codex +pyro host print-config opencode +pyro host doctor +pyro host repair opencode +``` + +These helpers wrap the same `pyro mcp serve` entrypoint, preserve the current +`workspace-core` default, and make it obvious how to repair drift later. + ## Claude Code +Preferred: + +```bash +pyro host connect claude-code +``` + +Repair: + +```bash +pyro host repair claude-code +``` + Package without install: ```bash @@ -66,6 +93,18 @@ Reference: ## Codex +Preferred: + +```bash +pyro host connect codex +``` + +Repair: + +```bash +pyro host repair codex +``` + Package without install: ```bash @@ -92,6 +131,13 @@ Reference: ## OpenCode +Preferred: + +```bash +pyro host print-config opencode +pyro host repair opencode +``` + Use the local MCP config shape from: - [opencode_mcp_config.json](../examples/opencode_mcp_config.json) diff --git a/docs/public-contract.md b/docs/public-contract.md index ae5c55e..b061cd5 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -94,6 +94,17 @@ Host-specific setup docs: - [opencode_mcp_config.json](../examples/opencode_mcp_config.json) - [mcp_client_config.md](../examples/mcp_client_config.md) +The chat-host bootstrap helper surface is: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` + +These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred +setup and repair path for supported hosts. + ## Chat-Facing Workspace Contract `workspace-core` is the normal chat path. It exposes: diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 6a40a81..ff6ccc6 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.1.0`: +Current baseline is `4.2.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -80,7 +80,7 @@ capability gaps: 10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done 11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done 12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done -13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Planned +13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done 14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Planned 15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned 16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned @@ -117,10 +117,12 @@ Completed so far: - `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit `--project-path` and `--repo-url` fallbacks when cwd is not the source of truth. +- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code, + Codex, and OpenCode users can connect or repair the supported chat-host path + without manually composing raw MCP commands or config edits. Planned next: -- [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) diff --git a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md index 4ccf34b..a9d07a4 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md +++ b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md @@ -1,6 +1,6 @@ # `4.2.0` Host Bootstrap And Repair -Status: Planned +Status: Done ## Goal diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index 3dbc5d0..0beb63c 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -2,6 +2,13 @@ Recommended profile: `workspace-core`. +Preferred helper flow: + +```bash +pyro host connect claude-code +pyro host doctor +``` + Package without install: ```bash @@ -23,9 +30,16 @@ If Claude Code launches the server from an unexpected cwd, pin the project explicitly: ```bash +pyro host connect claude-code --project-path /abs/path/to/repo claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo ``` +If the local config drifts later: + +```bash +pyro host repair claude-code +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index aa38813..330f38e 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -2,6 +2,13 @@ Recommended profile: `workspace-core`. +Preferred helper flow: + +```bash +pyro host connect codex +pyro host doctor +``` + Package without install: ```bash @@ -23,9 +30,16 @@ If Codex launches the server from an unexpected cwd, pin the project explicitly: ```bash +pyro host connect codex --project-path /abs/path/to/repo codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo ``` +If the local config drifts later: + +```bash +pyro host repair codex +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 10ee175..d0b2046 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -8,6 +8,14 @@ Use the host-specific examples first when they apply: - Codex: [examples/codex_mcp.md](codex_mcp.md) - OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json) +Preferred repair/bootstrap helpers: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair opencode` + Use this generic config only when the host expects a plain `mcpServers` JSON shape. diff --git a/pyproject.toml b/pyproject.toml index 7bf1bc9..1d6b8e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.1.0" +version = "4.2.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 4011d18..204df2a 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -8,12 +8,20 @@ import shlex import sys from pathlib import Path from textwrap import dedent -from typing import Any +from typing import Any, cast from pyro_mcp import __version__ -from pyro_mcp.api import Pyro +from pyro_mcp.api import McpToolProfile, Pyro from pyro_mcp.contract import PUBLIC_MCP_PROFILES from pyro_mcp.demo import run_demo +from pyro_mcp.host_helpers import ( + HostDoctorEntry, + HostServerConfig, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION @@ -169,6 +177,62 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: print(f"- {issue}") +def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig: + return HostServerConfig( + installed_package=bool(getattr(args, "installed_package", False)), + profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))), + project_path=getattr(args, "project_path", None), + repo_url=getattr(args, "repo_url", None), + repo_ref=getattr(args, "repo_ref", None), + no_project_source=bool(getattr(args, "no_project_source", False)), + ) + + +def _print_host_connect_human(payload: dict[str, Any]) -> None: + host = str(payload.get("host", "unknown")) + server_command = payload.get("server_command") + verification_command = payload.get("verification_command") + print(f"Connected pyro to {host}.") + if isinstance(server_command, list): + print("Server command: " + shlex.join(str(item) for item in server_command)) + if isinstance(verification_command, list): + print("Verify with: " + shlex.join(str(item) for item in verification_command)) + + +def _print_host_print_config_human(payload: dict[str, Any]) -> None: + rendered_config = payload.get("rendered_config") + if isinstance(rendered_config, str): + _write_stream(rendered_config, stream=sys.stdout) + return + output_path = payload.get("output_path") + if isinstance(output_path, str): + print(f"Wrote OpenCode config to {output_path}") + + +def _print_host_repair_human(payload: dict[str, Any]) -> None: + host = str(payload.get("host", "unknown")) + if host == "opencode": + print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.") + backup_path = payload.get("backup_path") + if isinstance(backup_path, str): + print(f"Backed up the previous config to {backup_path}.") + return + _print_host_connect_human(payload) + + +def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None: + for index, entry in enumerate(entries): + print( + f"{entry.host}: {entry.status} " + f"installed={'yes' if entry.installed else 'no'} " + f"configured={'yes' if entry.configured else 'no'}" + ) + print(f" details: {entry.details}") + print(f" repair: {entry.repair_command}") + if index != len(entries) - 1: + print() + + def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None: print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}") name = payload.get("name") @@ -645,6 +709,38 @@ class _HelpFormatter( return help_string +def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--installed-package", + action="store_true", + help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.", + ) + parser.add_argument( + "--profile", + choices=PUBLIC_MCP_PROFILES, + default="workspace-core", + help="Server profile to configure for the host helper flow.", + ) + source_group = parser.add_mutually_exclusive_group() + source_group.add_argument( + "--project-path", + help="Pin the server to this local project path instead of relying on host cwd.", + ) + source_group.add_argument( + "--repo-url", + help="Seed default workspaces from a clean clone of this repository URL.", + ) + source_group.add_argument( + "--no-project-source", + action="store_true", + help="Disable automatic Git checkout detection from the current working directory.", + ) + parser.add_argument( + "--repo-ref", + help="Optional branch, tag, or commit to checkout after cloning --repo-url.", + ) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( @@ -658,11 +754,12 @@ def _build_parser() -> argparse.ArgumentParser: pyro env list pyro env pull debian:12 pyro run debian:12 -- git --version - pyro mcp serve + pyro host connect claude-code Connect a chat host after that: - claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve - codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve + pyro host connect claude-code + pyro host connect codex + pyro host print-config opencode If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only @@ -767,6 +864,129 @@ def _build_parser() -> argparse.ArgumentParser: help="Print structured JSON instead of human-readable output.", ) + host_parser = subparsers.add_parser( + "host", + help="Bootstrap and repair supported chat-host configs.", + description=( + "Connect or repair the supported Claude Code, Codex, and OpenCode " + "host setups without hand-writing MCP commands or config." + ), + epilog=dedent( + """ + Examples: + pyro host connect claude-code + pyro host connect codex --project-path /abs/path/to/repo + pyro host print-config opencode + pyro host repair opencode + pyro host doctor + """ + ), + formatter_class=_HelpFormatter, + ) + host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST") + host_connect_parser = host_subparsers.add_parser( + "connect", + help="Connect Claude Code or Codex in one step.", + description=( + "Ensure the supported host has a `pyro` MCP server entry that wraps " + "the canonical `pyro mcp serve` command." + ), + epilog=dedent( + """ + Examples: + pyro host connect claude-code + pyro host connect codex --installed-package + pyro host connect codex --project-path /abs/path/to/repo + """ + ), + formatter_class=_HelpFormatter, + ) + host_connect_parser.add_argument( + "host", + choices=("claude-code", "codex"), + help="Chat host to connect and update in place.", + ) + _add_host_server_source_args(host_connect_parser) + + host_print_config_parser = host_subparsers.add_parser( + "print-config", + help="Print or write the canonical OpenCode config snippet.", + description=( + "Render the canonical OpenCode `mcp.pyro` config entry so it can be " + "copied into or written to `opencode.json`." + ), + epilog=dedent( + """ + Examples: + pyro host print-config opencode + pyro host print-config opencode --output ./opencode.json + pyro host print-config opencode --project-path /abs/path/to/repo + """ + ), + formatter_class=_HelpFormatter, + ) + host_print_config_parser.add_argument( + "host", + choices=("opencode",), + help="Host config shape to render.", + ) + _add_host_server_source_args(host_print_config_parser) + host_print_config_parser.add_argument( + "--output", + help="Write the rendered JSON to this path instead of printing it to stdout.", + ) + + host_doctor_parser = host_subparsers.add_parser( + "doctor", + help="Inspect supported host setup status.", + description=( + "Report whether Claude Code, Codex, and OpenCode are installed, " + "configured, missing, or drifted relative to the canonical `pyro` MCP setup." + ), + epilog=dedent( + """ + Examples: + pyro host doctor + pyro host doctor --project-path /abs/path/to/repo + pyro host doctor --installed-package + """ + ), + formatter_class=_HelpFormatter, + ) + _add_host_server_source_args(host_doctor_parser) + host_doctor_parser.add_argument( + "--config-path", + help="Override the OpenCode config path when inspecting or repairing that host.", + ) + + host_repair_parser = host_subparsers.add_parser( + "repair", + help="Repair one supported host to the canonical `pyro` setup.", + description=( + "Repair a stale or broken host config by reapplying the canonical " + "`pyro mcp serve` setup for that host." + ), + epilog=dedent( + """ + Examples: + pyro host repair claude-code + pyro host repair codex --project-path /abs/path/to/repo + pyro host repair opencode + """ + ), + formatter_class=_HelpFormatter, + ) + host_repair_parser.add_argument( + "host", + choices=("claude-code", "codex", "opencode"), + help="Host config to repair.", + ) + _add_host_server_source_args(host_repair_parser) + host_repair_parser.add_argument( + "--config-path", + help="Override the OpenCode config path when repairing that host.", + ) + mcp_parser = subparsers.add_parser( "mcp", help="Run the MCP server.", @@ -2350,6 +2570,57 @@ def main() -> None: else: _print_prune_human(prune_payload) return + if args.command == "host": + config = _build_host_server_config(args) + if args.host_command == "connect": + try: + payload = connect_cli_host(args.host, config=config) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_connect_human(payload) + return + if args.host_command == "print-config": + try: + output_path = ( + None if args.output is None else Path(args.output).expanduser().resolve() + ) + payload = print_or_write_opencode_config(config=config, output_path=output_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_print_config_human(payload) + return + if args.host_command == "doctor": + try: + config_path = ( + None + if args.config_path is None + else Path(args.config_path).expanduser().resolve() + ) + entries = doctor_hosts(config=config, config_path=config_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_doctor_human(entries) + return + if args.host_command == "repair": + try: + if args.host != "opencode" and args.config_path is not None: + raise ValueError( + "--config-path is only supported for `pyro host repair opencode`" + ) + config_path = ( + None + if args.config_path is None + else Path(args.config_path).expanduser().resolve() + ) + payload = repair_host(args.host, config=config, config_path=config_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_repair_human(payload) + return if args.command == "mcp": pyro.create_server( profile=args.profile, @@ -2497,7 +2768,8 @@ def main() -> None: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_exec_human(payload) - exit_code = int(payload.get("exit_code", 1)) + exit_code_raw = payload.get("exit_code", 1) + exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1 if exit_code != 0: raise SystemExit(exit_code) return diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 68ce11f..f706633 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,9 +2,22 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") +PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") +PUBLIC_CLI_HOST_COMMON_FLAGS = ( + "--installed-package", + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) +PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS +PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) +PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",) +PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) PUBLIC_CLI_MCP_SERVE_FLAGS = ( "--profile", diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py new file mode 100644 index 0000000..42420bb --- /dev/null +++ b/src/pyro_mcp/host_helpers.py @@ -0,0 +1,363 @@ +"""Helpers for bootstrapping and repairing supported MCP chat hosts.""" + +from __future__ import annotations + +import json +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal + +from pyro_mcp.api import McpToolProfile + +SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex") +SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") +SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",) +DEFAULT_HOST_SERVER_NAME = "pyro" +DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json" + +HostStatus = Literal["drifted", "missing", "ok", "unavailable"] + + +@dataclass(frozen=True) +class HostServerConfig: + installed_package: bool = False + profile: McpToolProfile = "workspace-core" + project_path: str | None = None + repo_url: str | None = None + repo_ref: str | None = None + no_project_source: bool = False + + +@dataclass(frozen=True) +class HostDoctorEntry: + host: str + installed: bool + configured: bool + status: HostStatus + details: str + repair_command: str + + +def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + check=False, + capture_output=True, + text=True, + ) + + +def _host_binary(host: str) -> str: + if host == "claude-code": + return "claude" + if host == "codex": + return "codex" + raise ValueError(f"unsupported CLI host {host!r}") + + +def _canonical_server_command(config: HostServerConfig) -> list[str]: + if config.project_path is not None and config.repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if config.no_project_source and ( + config.project_path is not None + or config.repo_url is not None + or config.repo_ref is not None + ): + raise ValueError( + "--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref" + ) + if config.repo_ref is not None and config.repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + + command = ["pyro", "mcp", "serve"] + if not config.installed_package: + command = ["uvx", "--from", "pyro-mcp", *command] + if config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + return command + + +def _render_cli_command(command: list[str]) -> str: + return shlex.join(command) + + +def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str: + command = ["pyro", "host", "repair", host] + if config.installed_package: + command.append("--installed-package") + if config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + if config_path is not None: + command.extend(["--config-path", str(config_path)]) + return _render_cli_command(command) + + +def _command_matches(output: str, expected: list[str]) -> bool: + normalized_output = output.strip() + if ":" in normalized_output: + normalized_output = normalized_output.split(":", 1)[1].strip() + try: + parsed = shlex.split(normalized_output) + except ValueError: + parsed = normalized_output.split() + return parsed == expected + + +def _upsert_opencode_config( + *, + config_path: Path, + config: HostServerConfig, +) -> tuple[dict[str, object], Path | None]: + existing_payload: dict[str, object] = {} + backup_path: Path | None = None + if config_path.exists(): + raw_text = config_path.read_text(encoding="utf-8") + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + parsed = {} + if isinstance(parsed, dict): + existing_payload = parsed + else: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + payload = dict(existing_payload) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict): + mcp_payload = {} + else: + mcp_payload = dict(mcp_payload) + mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config) + payload["mcp"] = mcp_payload + return payload, backup_path + + +def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]: + return { + "type": "local", + "enabled": True, + "command": _canonical_server_command(config), + } + + +def render_opencode_config(config: HostServerConfig) -> str: + return ( + json.dumps( + {"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}}, + indent=2, + ) + + "\n" + ) + + +def print_or_write_opencode_config( + *, + config: HostServerConfig, + output_path: Path | None = None, +) -> dict[str, object]: + rendered = render_opencode_config(config) + if output_path is None: + return { + "host": "opencode", + "rendered_config": rendered, + "server_command": _canonical_server_command(config), + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + return { + "host": "opencode", + "output_path": str(output_path), + "server_command": _canonical_server_command(config), + } + + +def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]: + binary = _host_binary(host) + if shutil.which(binary) is None: + raise RuntimeError(f"{binary} CLI is not installed or not on PATH") + server_command = _canonical_server_command(config) + _run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME]) + result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command]) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed" + raise RuntimeError(details) + return { + "host": host, + "server_command": server_command, + "verification_command": [binary, "mcp", "list"], + } + + +def repair_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config) + resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + result: dict[str, object] = { + "host": "opencode", + "config_path": str(resolved_path), + "server_command": _canonical_server_command(config), + } + if backup_path is not None: + result["backup_path"] = str(backup_path) + return result + + +def repair_host( + host: str, + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + if host == "opencode": + return repair_opencode_host(config=config, config_path=config_path) + return connect_cli_host(host, config=config) + + +def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry: + binary = _host_binary(host) + repair_command = _repair_command(host, config) + if shutil.which(binary) is None: + return HostDoctorEntry( + host=host, + installed=False, + configured=False, + status="unavailable", + details=f"{binary} CLI was not found on PATH", + repair_command=repair_command, + ) + expected_command = _canonical_server_command(config) + get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME]) + combined_get_output = (get_result.stdout + get_result.stderr).strip() + if get_result.returncode == 0: + status: HostStatus = ( + "ok" if _command_matches(combined_get_output, expected_command) else "drifted" + ) + return HostDoctorEntry( + host=host, + installed=True, + configured=True, + status=status, + details=combined_get_output or f"{binary} MCP entry exists", + repair_command=repair_command, + ) + + list_result = _run_command([binary, "mcp", "list"]) + combined_list_output = (list_result.stdout + list_result.stderr).strip() + configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split() + return HostDoctorEntry( + host=host, + installed=True, + configured=configured, + status="drifted" if configured else "missing", + details=combined_get_output or combined_list_output or f"{binary} MCP entry missing", + repair_command=repair_command, + ) + + +def _doctor_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> HostDoctorEntry: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + repair_command = _repair_command("opencode", config, config_path=config_path) + installed = shutil.which("opencode") is not None + if not resolved_path.exists(): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config missing at {resolved_path}", + repair_command=repair_command, + ) + try: + payload = json.loads(resolved_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details=f"OpenCode config is invalid JSON: {exc}", + repair_command=repair_command, + ) + if not isinstance(payload, dict): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details="OpenCode config must be a JSON object", + repair_command=repair_command, + ) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config at {resolved_path} is missing mcp.pyro", + repair_command=repair_command, + ) + configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME] + expected_entry = canonical_opencode_entry(config) + status: HostStatus = "ok" if configured_entry == expected_entry else "drifted" + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=True, + status=status, + details=f"OpenCode config path: {resolved_path}", + repair_command=repair_command, + ) + + +def doctor_hosts( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> list[HostDoctorEntry]: + return [ + _doctor_cli_host("claude-code", config=config), + _doctor_cli_host("codex", config=config), + _doctor_opencode_host(config=config, config_path=config_path), + ] diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 1ba41bb..a6cbd46 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.1.0" +DEFAULT_CATALOG_VERSION = "4.2.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.1.0,<5.0.0" + compatibility: str = ">=4.2.0,<5.0.0" @dataclass(frozen=True) diff --git a/tests/test_api.py b/tests/test_api.py index 29bda64..538e419 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ from pyro_mcp.vm_network import TapNetworkManager def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 - ["git", *args], + ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, diff --git a/tests/test_cli.py b/tests/test_cli.py index 4cd085c..b55e752 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ from typing import Any, cast import pytest import pyro_mcp.cli as cli +from pyro_mcp.host_helpers import HostDoctorEntry def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser: @@ -31,10 +32,11 @@ def test_cli_help_guides_first_run() -> None: assert "pyro env list" in help_text assert "pyro env pull debian:12" in help_text assert "pyro run debian:12 -- git --version" in help_text - assert "pyro mcp serve" in help_text + assert "pyro host connect claude-code" in help_text assert "Connect a chat host after that:" in help_text - assert "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text - assert "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text + assert "pyro host connect claude-code" in help_text + assert "pyro host connect codex" in help_text + assert "pyro host print-config opencode" in help_text assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text @@ -57,6 +59,31 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro env pull debian:12" in env_help assert "downloads from public Docker Hub" in env_help + host_help = _subparser_choice(parser, "host").format_help() + assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help + assert "pyro host connect claude-code" in host_help + assert "pyro host repair opencode" in host_help + + host_connect_help = _subparser_choice( + _subparser_choice(parser, "host"), "connect" + ).format_help() + assert "--installed-package" in host_connect_help + assert "--project-path" in host_connect_help + assert "--repo-url" in host_connect_help + assert "--repo-ref" in host_connect_help + assert "--no-project-source" in host_connect_help + + host_print_config_help = _subparser_choice( + _subparser_choice(parser, "host"), "print-config" + ).format_help() + assert "--output" in host_print_config_help + + host_doctor_help = _subparser_choice(_subparser_choice(parser, "host"), "doctor").format_help() + assert "--config-path" in host_doctor_help + + host_repair_help = _subparser_choice(_subparser_choice(parser, "host"), "repair").format_help() + assert "--config-path" in host_repair_help + doctor_help = _subparser_choice(parser, "doctor").format_help() assert "Check host prerequisites and embedded runtime health" in doctor_help assert "pyro doctor --json" in doctor_help @@ -316,6 +343,94 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "Close a persistent workspace shell" in workspace_shell_close_help +def test_cli_host_connect_dispatch( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + pass + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="host", + host_command="connect", + host="codex", + installed_package=False, + profile="workspace-core", + project_path=None, + repo_url=None, + repo_ref=None, + no_project_source=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr( + cli, + "connect_cli_host", + lambda host, *, config: { + "host": host, + "server_command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + "verification_command": ["codex", "mcp", "list"], + }, + ) + + cli.main() + captured = capsys.readouterr() + assert captured.out == ( + "Connected pyro to codex.\n" + "Server command: uvx --from pyro-mcp pyro mcp serve\n" + "Verify with: codex mcp list\n" + ) + assert captured.err == "" + + +def test_cli_host_doctor_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + pass + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="host", + host_command="doctor", + installed_package=False, + profile="workspace-core", + project_path=None, + repo_url=None, + repo_ref=None, + no_project_source=False, + config_path=None, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr( + cli, + "doctor_hosts", + lambda **_: [ + HostDoctorEntry( + host="codex", + installed=True, + configured=False, + status="missing", + details="codex entry missing", + repair_command="pyro host repair codex", + ) + ], + ) + + cli.main() + captured = capsys.readouterr() + assert "codex: missing installed=yes configured=no" in captured.out + assert "repair: pyro host repair codex" in captured.out + assert captured.err == "" + + def test_cli_run_prints_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -2823,30 +2938,38 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8") codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8") opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8")) + claude_helper = "pyro host connect claude-code" + codex_helper = "pyro host connect codex" + opencode_helper = "pyro host print-config opencode" claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" assert "## Chat Host Quickstart" in readme - assert "uvx --from pyro-mcp pyro mcp serve" in readme - assert claude_cmd in readme - assert codex_cmd in readme + assert claude_helper in readme + assert codex_helper in readme + assert opencode_helper in readme assert "examples/opencode_mcp_config.json" in readme + assert "pyro host doctor" in readme assert "bare `pyro mcp serve` starts `workspace-core`" in readme - assert "auto-detects\nthe current Git checkout" in readme + assert "auto-detects the current Git checkout" in readme.replace("\n", " ") assert "--project-path /abs/path/to/repo" in readme assert "--repo-url https://github.com/example/project.git" in readme assert "## 5. Connect a chat host" in install - assert "uvx --from pyro-mcp pyro mcp serve" in install - assert claude_cmd in install - assert codex_cmd in install + assert claude_helper in install + assert codex_helper in install + assert opencode_helper in install assert "workspace-full" in install assert "--project-path /abs/path/to/repo" in install - assert claude_cmd in first_run - assert codex_cmd in first_run + assert claude_helper in first_run + assert codex_helper in first_run + assert opencode_helper in first_run assert "--project-path /abs/path/to/repo" in first_run + assert claude_helper in integrations + assert codex_helper in integrations + assert opencode_helper in integrations assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations assert "auto-detects the current Git checkout" in integrations assert "examples/claude_code_mcp.md" in integrations @@ -2862,13 +2985,17 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert "codex_mcp.md" in mcp_config assert "opencode_mcp_config.json" in mcp_config + assert claude_helper in claude_code assert claude_cmd in claude_code assert "claude mcp list" in claude_code + assert "pyro host repair claude-code" in claude_code assert "workspace-full" in claude_code assert "--project-path /abs/path/to/repo" in claude_code + assert codex_helper in codex assert codex_cmd in codex assert "codex mcp list" in codex + assert "pyro host repair codex" in codex assert "workspace-full" in codex assert "--project-path /abs/path/to/repo" in codex diff --git a/tests/test_host_helpers.py b/tests/test_host_helpers.py new file mode 100644 index 0000000..0f84ae1 --- /dev/null +++ b/tests/test_host_helpers.py @@ -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"]] diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py index 667c05f..fd8a6b1 100644 --- a/tests/test_project_startup.py +++ b/tests/test_project_startup.py @@ -5,6 +5,7 @@ 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, @@ -15,7 +16,7 @@ from pyro_mcp.project_startup import ( def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 - ["git", *args], + ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, @@ -76,6 +77,52 @@ def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Pa 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") @@ -93,6 +140,60 @@ def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_pat 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") @@ -113,3 +214,23 @@ def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_pa 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 diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 5033d4a..35aad8f 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -16,6 +16,11 @@ from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, PUBLIC_CLI_ENV_SUBCOMMANDS, + PUBLIC_CLI_HOST_CONNECT_FLAGS, + PUBLIC_CLI_HOST_DOCTOR_FLAGS, + PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS, + PUBLIC_CLI_HOST_REPAIR_FLAGS, + PUBLIC_CLI_HOST_SUBCOMMANDS, PUBLIC_CLI_MCP_SERVE_FLAGS, PUBLIC_CLI_MCP_SUBCOMMANDS, PUBLIC_CLI_RUN_FLAGS, @@ -102,6 +107,29 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text + host_help_text = _subparser_choice(parser, "host").format_help() + for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS: + assert subcommand_name in host_help_text + host_connect_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "connect" + ).format_help() + for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS: + assert flag in host_connect_help_text + host_doctor_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "doctor" + ).format_help() + for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS: + assert flag in host_doctor_help_text + host_print_config_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "print-config" + ).format_help() + for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS: + assert flag in host_print_config_help_text + host_repair_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "repair" + ).format_help() + for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS: + assert flag in host_repair_help_text mcp_help_text = _subparser_choice(parser, "mcp").format_help() for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: assert subcommand_name in mcp_help_text diff --git a/tests/test_server.py b/tests/test_server.py index f36c1ff..e52c2d0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,7 +19,7 @@ from pyro_mcp.vm_network import TapNetworkManager def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 - ["git", *args], + ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, diff --git a/tests/test_workspace_ports.py b/tests/test_workspace_ports.py index d63cfae..54a3fbe 100644 --- a/tests/test_workspace_ports.py +++ b/tests/test_workspace_ports.py @@ -15,6 +15,13 @@ import pytest from pyro_mcp import workspace_ports +def _socketpair_or_skip() -> tuple[socket.socket, socket.socket]: + try: + return socket.socketpair() + except PermissionError as exc: + pytest.skip(f"socketpair unavailable in this environment: {exc}") + + class _EchoHandler(socketserver.BaseRequestHandler): def handle(self) -> None: data = self.request.recv(65536) @@ -50,18 +57,26 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure( def test_workspace_port_proxy_forwards_tcp_traffic() -> None: - upstream = socketserver.ThreadingTCPServer( - (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), - _EchoHandler, - ) + try: + upstream = socketserver.ThreadingTCPServer( + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + _EchoHandler, + ) + except PermissionError as exc: + pytest.skip(f"TCP bind unavailable in this environment: {exc}") upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) upstream_thread.start() upstream_host = str(upstream.server_address[0]) upstream_port = int(upstream.server_address[1]) - proxy = workspace_ports._ProxyServer( # noqa: SLF001 - (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), - (upstream_host, upstream_port), - ) + try: + proxy = workspace_ports._ProxyServer( # noqa: SLF001 + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + (upstream_host, upstream_port), + ) + except PermissionError as exc: + upstream.shutdown() + upstream.server_close() + pytest.skip(f"proxy TCP bind unavailable in this environment: {exc}") proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) proxy_thread.start() try: @@ -202,8 +217,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server( def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events( monkeypatch: Any, ) -> None: - source, source_peer = socket.socketpair() - upstream, upstream_peer = socket.socketpair() + source, source_peer = _socketpair_or_skip() + upstream, upstream_peer = _socketpair_or_skip() source_peer.close() class FakeSelector: @@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors( monkeypatch: Any, ) -> None: def _run_once(*, close_source: bool) -> None: - source, source_peer = socket.socketpair() - upstream, upstream_peer = socket.socketpair() + source, source_peer = _socketpair_or_skip() + upstream, upstream_peer = _socketpair_or_skip() if not close_source: - source_peer.sendall(b"hello") + try: + source_peer.sendall(b"hello") + except PermissionError as exc: + source.close() + source_peer.close() + upstream.close() + upstream_peer.close() + pytest.skip(f"socket send unavailable in this environment: {exc}") class FakeSelector: def register(self, *_args: Any, **_kwargs: Any) -> None: diff --git a/uv.lock b/uv.lock index eb006fe..cc18ce9 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.1.0" +version = "4.2.0" source = { editable = "." } dependencies = [ { name = "mcp" },