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:
Thales Maciel 2026-03-13 16:46:10 -03:00
parent 535efc6919
commit 899a6760c4
25 changed files with 1658 additions and 58 deletions

View file

@ -2,6 +2,18 @@
All notable user-visible changes to `pyro-mcp` are documented here. 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 ## 4.1.0
- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can - Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can

View file

@ -1,6 +1,7 @@
PYTHON ?= uv run python PYTHON ?= uv run python
UV_CACHE_DIR ?= .uv-cache 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_BASE_URL ?= http://localhost:11434/v1
OLLAMA_MODEL ?= llama3.2:3b OLLAMA_MODEL ?= llama3.2:3b
OLLAMA_DEMO_FLAGS ?= OLLAMA_DEMO_FLAGS ?=
@ -84,6 +85,8 @@ check: lint typecheck test
dist-check: dist-check:
uv run pyro --version uv run pyro --version
uv run pyro --help >/dev/null 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 mcp --help >/dev/null
uv run pyro run --help >/dev/null uv run pyro run --help >/dev/null
uv run pyro env list >/dev/null uv run pyro env list >/dev/null

View file

@ -30,7 +30,7 @@ SDK-first platform.
- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md)
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - 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) - 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/) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
## Who It's For ## Who It's For
@ -76,7 +76,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 4.1.0 Catalog version: 4.2.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12 [pull] phase=ready environment=debian:12
@ -96,9 +96,26 @@ for the guest image.
## Chat Host Quickstart ## Chat Host Quickstart
After the quickstart works, the intended next step is to connect a chat host. 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 Use the helper flow first:
the current Git checkout, and lets the first `workspace_create` omit
`seed_path`. ```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 ```bash
uvx --from pyro-mcp pyro mcp serve 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: If the host does not preserve the server working directory, use:
```bash ```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 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: If you are starting outside a local checkout, use a clean clone source:
```bash ```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 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 `"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command
array. array.
@ -163,9 +183,9 @@ snapshots, secrets, network policy, or disk tools.
1. Validate the host with `pyro doctor`. 1. Validate the host with `pyro doctor`.
2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. 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 3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
root, or use `--project-path` / `--repo-url` when cwd is not the source of `pyro host print-config opencode`, then fall back to raw `pyro mcp serve`
truth. 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). 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. `repro-fix-loop` is the shortest chat-first story.
5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path 5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path

View file

@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ 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 [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-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. 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 ## 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: Claude Code:
```bash ```bash
$ uvx --from pyro-mcp pyro host connect claude-code
$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve $ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ claude mcp list $ claude mcp list
``` ```
@ -103,6 +121,7 @@ $ claude mcp list
Codex: Codex:
```bash ```bash
$ uvx --from pyro-mcp pyro host connect codex
$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve $ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
$ codex mcp list $ codex mcp list
``` ```

View file

@ -62,7 +62,8 @@ pyro run debian:12 -- git --version
If you are running from a repo checkout instead, replace `pyro` with If you are running from a repo checkout instead, replace `pyro` with
`uv run pyro`. `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 ## 1. Check the host
@ -92,7 +93,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```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 [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-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. 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 ## 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 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 auto-detects the current Git checkout so the first `workspace_create` can omit
`seed_path`. `seed_path`.
@ -170,12 +188,14 @@ Copy-paste host-specific starts:
Claude Code: Claude Code:
```bash ```bash
pyro host connect claude-code
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
``` ```
Codex: Codex:
```bash ```bash
pyro host connect codex
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve 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` 1. validate the host with `pyro doctor`
2. pull `debian:12` 2. pull `debian:12`
3. prove guest execution with `pyro run debian:12 -- git --version` 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 4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or
root, or use `--project-path` / `--repo-url` when needed `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) 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` 6. trust but verify with `make smoke-use-cases`

View file

@ -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, Use `--profile workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools. 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 ## Claude Code
Preferred:
```bash
pyro host connect claude-code
```
Repair:
```bash
pyro host repair claude-code
```
Package without install: Package without install:
```bash ```bash
@ -66,6 +93,18 @@ Reference:
## Codex ## Codex
Preferred:
```bash
pyro host connect codex
```
Repair:
```bash
pyro host repair codex
```
Package without install: Package without install:
```bash ```bash
@ -92,6 +131,13 @@ Reference:
## OpenCode ## OpenCode
Preferred:
```bash
pyro host print-config opencode
pyro host repair opencode
```
Use the local MCP config shape from: Use the local MCP config shape from:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) - [opencode_mcp_config.json](../examples/opencode_mcp_config.json)

View file

@ -94,6 +94,17 @@ Host-specific setup docs:
- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) - [opencode_mcp_config.json](../examples/opencode_mcp_config.json)
- [mcp_client_config.md](../examples/mcp_client_config.md) - [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 ## Chat-Facing Workspace Contract
`workspace-core` is the normal chat path. It exposes: `workspace-core` is the normal chat path. It exposes:

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface. interface.
Current baseline is `4.1.0`: Current baseline is `4.2.0`:
- `pyro mcp serve` is now the default product entrypoint - `pyro mcp serve` is now the default product entrypoint
- `workspace-core` is now the default MCP profile - `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 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 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 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 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 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 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 - `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 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. `--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: 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.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.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) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md)

View file

@ -1,6 +1,6 @@
# `4.2.0` Host Bootstrap And Repair # `4.2.0` Host Bootstrap And Repair
Status: Planned Status: Done
## Goal ## Goal

View file

@ -2,6 +2,13 @@
Recommended profile: `workspace-core`. Recommended profile: `workspace-core`.
Preferred helper flow:
```bash
pyro host connect claude-code
pyro host doctor
```
Package without install: Package without install:
```bash ```bash
@ -23,9 +30,16 @@ If Claude Code launches the server from an unexpected cwd, pin the project
explicitly: explicitly:
```bash ```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 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, Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools: snapshots, secrets, network policy, or disk tools:

View file

@ -2,6 +2,13 @@
Recommended profile: `workspace-core`. Recommended profile: `workspace-core`.
Preferred helper flow:
```bash
pyro host connect codex
pyro host doctor
```
Package without install: Package without install:
```bash ```bash
@ -23,9 +30,16 @@ If Codex launches the server from an unexpected cwd, pin the project
explicitly: explicitly:
```bash ```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 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, Move to `workspace-full` only when the chat truly needs shells, services,
snapshots, secrets, network policy, or disk tools: snapshots, secrets, network policy, or disk tools:

View file

@ -8,6 +8,14 @@ Use the host-specific examples first when they apply:
- Codex: [examples/codex_mcp.md](codex_mcp.md) - Codex: [examples/codex_mcp.md](codex_mcp.md)
- OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json) - 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 Use this generic config only when the host expects a plain `mcpServers` JSON
shape. shape.

View file

@ -1,6 +1,6 @@
[project] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "4.1.0" version = "4.2.0"
description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -8,12 +8,20 @@ import shlex
import sys import sys
from pathlib import Path from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Any from typing import Any, cast
from pyro_mcp import __version__ 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.contract import PUBLIC_MCP_PROFILES
from pyro_mcp.demo import run_demo 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.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.runtime import DEFAULT_PLATFORM, doctor_report
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION 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}") 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: def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}") print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
name = payload.get("name") name = payload.get("name")
@ -645,6 +709,38 @@ class _HelpFormatter(
return help_string 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: def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=( description=(
@ -658,11 +754,12 @@ def _build_parser() -> argparse.ArgumentParser:
pyro env list pyro env list
pyro env pull debian:12 pyro env pull debian:12
pyro run debian:12 -- git --version pyro run debian:12 -- git --version
pyro mcp serve pyro host connect claude-code
Connect a chat host after that: Connect a chat host after that:
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve pyro host connect claude-code
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve pyro host connect codex
pyro host print-config opencode
If you want terminal-level visibility into the workspace model: If you want terminal-level visibility into the workspace model:
pyro workspace create debian:12 --seed-path ./repo --id-only 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.", 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_parser = subparsers.add_parser(
"mcp", "mcp",
help="Run the MCP server.", help="Run the MCP server.",
@ -2350,6 +2570,57 @@ def main() -> None:
else: else:
_print_prune_human(prune_payload) _print_prune_human(prune_payload)
return 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": if args.command == "mcp":
pyro.create_server( pyro.create_server(
profile=args.profile, profile=args.profile,
@ -2497,7 +2768,8 @@ def main() -> None:
print(f"[error] {exc}", file=sys.stderr, flush=True) print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc raise SystemExit(1) from exc
_print_workspace_exec_human(payload) _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: if exit_code != 0:
raise SystemExit(exit_code) raise SystemExit(exit_code)
return return

View file

@ -2,9 +2,22 @@
from __future__ import annotations 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_DEMO_SUBCOMMANDS = ("ollama",)
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") 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_SUBCOMMANDS = ("serve",)
PUBLIC_CLI_MCP_SERVE_FLAGS = ( PUBLIC_CLI_MCP_SERVE_FLAGS = (
"--profile", "--profile",

View file

@ -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),
]

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0" DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "4.1.0" DEFAULT_CATALOG_VERSION = "4.2.0"
OCI_MANIFEST_ACCEPT = ", ".join( OCI_MANIFEST_ACCEPT = ", ".join(
( (
"application/vnd.oci.image.index.v1+json", "application/vnd.oci.image.index.v1+json",
@ -48,7 +48,7 @@ class VmEnvironment:
oci_repository: str | None = None oci_repository: str | None = None
oci_reference: str | None = None oci_reference: str | None = None
source_digest: 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) @dataclass(frozen=True)

View file

@ -18,7 +18,7 @@ from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str: def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603 result = subprocess.run( # noqa: S603
["git", *args], ["git", "-c", "commit.gpgsign=false", *args],
cwd=repo, cwd=repo,
check=True, check=True,
capture_output=True, capture_output=True,

View file

@ -9,6 +9,7 @@ from typing import Any, cast
import pytest import pytest
import pyro_mcp.cli as cli import pyro_mcp.cli as cli
from pyro_mcp.host_helpers import HostDoctorEntry
def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser: 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 list" in help_text
assert "pyro env pull debian:12" in help_text assert "pyro env pull debian:12" in help_text
assert "pyro run debian:12 -- git --version" 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 "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 "pyro host connect claude-code" in help_text
assert "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" 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 "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 exec WORKSPACE_ID -- cat note.txt" in help_text
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" 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 "pyro env pull debian:12" in env_help
assert "downloads from public Docker Hub" 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() doctor_help = _subparser_choice(parser, "doctor").format_help()
assert "Check host prerequisites and embedded runtime health" in doctor_help assert "Check host prerequisites and embedded runtime health" in doctor_help
assert "pyro doctor --json" 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 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( def test_cli_run_prints_json(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], 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") claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8")
codex = Path("examples/codex_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")) 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" 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" codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve"
assert "## Chat Host Quickstart" in readme assert "## Chat Host Quickstart" in readme
assert "uvx --from pyro-mcp pyro mcp serve" in readme assert claude_helper in readme
assert claude_cmd in readme assert codex_helper in readme
assert codex_cmd in readme assert opencode_helper in readme
assert "examples/opencode_mcp_config.json" 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 "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 "--project-path /abs/path/to/repo" in readme
assert "--repo-url https://github.com/example/project.git" in readme assert "--repo-url https://github.com/example/project.git" in readme
assert "## 5. Connect a chat host" in install assert "## 5. Connect a chat host" in install
assert "uvx --from pyro-mcp pyro mcp serve" in install assert claude_helper in install
assert claude_cmd in install assert codex_helper in install
assert codex_cmd in install assert opencode_helper in install
assert "workspace-full" in install assert "workspace-full" in install
assert "--project-path /abs/path/to/repo" in install assert "--project-path /abs/path/to/repo" in install
assert claude_cmd in first_run assert claude_helper in first_run
assert codex_cmd 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 "--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 "Bare `pyro mcp serve` starts `workspace-core`." in integrations
assert "auto-detects the current Git checkout" in integrations assert "auto-detects the current Git checkout" in integrations
assert "examples/claude_code_mcp.md" 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 "codex_mcp.md" in mcp_config
assert "opencode_mcp_config.json" 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_cmd in claude_code
assert "claude mcp list" 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 "workspace-full" in claude_code
assert "--project-path /abs/path/to/repo" 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_cmd in codex
assert "codex mcp list" in codex assert "codex mcp list" in codex
assert "pyro host repair codex" in codex
assert "workspace-full" in codex assert "workspace-full" in codex
assert "--project-path /abs/path/to/repo" in codex assert "--project-path /abs/path/to/repo" in codex

484
tests/test_host_helpers.py Normal file
View 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"]]

View file

@ -5,6 +5,7 @@ from pathlib import Path
import pytest import pytest
import pyro_mcp.project_startup as project_startup
from pyro_mcp.project_startup import ( from pyro_mcp.project_startup import (
ProjectStartupSource, ProjectStartupSource,
describe_project_startup_source, describe_project_startup_source,
@ -15,7 +16,7 @@ from pyro_mcp.project_startup import (
def _git(repo: Path, *args: str) -> str: def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603 result = subprocess.run( # noqa: S603
["git", *args], ["git", "-c", "commit.gpgsign=false", *args],
cwd=repo, cwd=repo,
check=True, check=True,
capture_output=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) 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: 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") repo = _make_repo(tmp_path / "repo", content="one\n")
first_commit = _git(repo, "rev-parse", "HEAD") 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" 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: def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None:
repo = _make_repo(tmp_path / "repo") 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 project_description == f"the current project at {repo.resolve()}"
assert repo_description == "the clean clone source https://example.com/repo.git at ref main" 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

View file

@ -16,6 +16,11 @@ from pyro_mcp.contract import (
PUBLIC_CLI_COMMANDS, PUBLIC_CLI_COMMANDS,
PUBLIC_CLI_DEMO_SUBCOMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS,
PUBLIC_CLI_ENV_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_SERVE_FLAGS,
PUBLIC_CLI_MCP_SUBCOMMANDS, PUBLIC_CLI_MCP_SUBCOMMANDS,
PUBLIC_CLI_RUN_FLAGS, 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() env_help_text = _subparser_choice(parser, "env").format_help()
for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS:
assert subcommand_name in env_help_text 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() mcp_help_text = _subparser_choice(parser, "mcp").format_help()
for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS:
assert subcommand_name in mcp_help_text assert subcommand_name in mcp_help_text

View file

@ -19,7 +19,7 @@ from pyro_mcp.vm_network import TapNetworkManager
def _git(repo: Path, *args: str) -> str: def _git(repo: Path, *args: str) -> str:
result = subprocess.run( # noqa: S603 result = subprocess.run( # noqa: S603
["git", *args], ["git", "-c", "commit.gpgsign=false", *args],
cwd=repo, cwd=repo,
check=True, check=True,
capture_output=True, capture_output=True,

View file

@ -15,6 +15,13 @@ import pytest
from pyro_mcp import workspace_ports 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): class _EchoHandler(socketserver.BaseRequestHandler):
def handle(self) -> None: def handle(self) -> None:
data = self.request.recv(65536) 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: def test_workspace_port_proxy_forwards_tcp_traffic() -> None:
upstream = socketserver.ThreadingTCPServer( try:
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), upstream = socketserver.ThreadingTCPServer(
_EchoHandler, (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 = threading.Thread(target=upstream.serve_forever, daemon=True)
upstream_thread.start() upstream_thread.start()
upstream_host = str(upstream.server_address[0]) upstream_host = str(upstream.server_address[0])
upstream_port = int(upstream.server_address[1]) upstream_port = int(upstream.server_address[1])
proxy = workspace_ports._ProxyServer( # noqa: SLF001 try:
(workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), proxy = workspace_ports._ProxyServer( # noqa: SLF001
(upstream_host, upstream_port), (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 = threading.Thread(target=proxy.serve_forever, daemon=True)
proxy_thread.start() proxy_thread.start()
try: 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( def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events(
monkeypatch: Any, monkeypatch: Any,
) -> None: ) -> None:
source, source_peer = socket.socketpair() source, source_peer = _socketpair_or_skip()
upstream, upstream_peer = socket.socketpair() upstream, upstream_peer = _socketpair_or_skip()
source_peer.close() source_peer.close()
class FakeSelector: class FakeSelector:
@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors(
monkeypatch: Any, monkeypatch: Any,
) -> None: ) -> None:
def _run_once(*, close_source: bool) -> None: def _run_once(*, close_source: bool) -> None:
source, source_peer = socket.socketpair() source, source_peer = _socketpair_or_skip()
upstream, upstream_peer = socket.socketpair() upstream, upstream_peer = _socketpair_or_skip()
if not close_source: 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: class FakeSelector:
def register(self, *_args: Any, **_kwargs: Any) -> None: def register(self, *_args: Any, **_kwargs: Any) -> None:

2
uv.lock generated
View file

@ -715,7 +715,7 @@ crypto = [
[[package]] [[package]]
name = "pyro-mcp" name = "pyro-mcp"
version = "4.1.0" version = "4.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },