Add host bootstrap and repair helpers
Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits. Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material. Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here. Validation: - uv lock - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
This commit is contained in:
parent
535efc6919
commit
899a6760c4
25 changed files with 1658 additions and 58 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
38
README.md
38
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# `4.2.0` Host Bootstrap And Repair
|
# `4.2.0` Host Bootstrap And Repair
|
||||||
|
|
||||||
Status: Planned
|
Status: Done
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
363
src/pyro_mcp/host_helpers.py
Normal file
363
src/pyro_mcp/host_helpers.py
Normal 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),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
484
tests/test_host_helpers.py
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CompletedProcess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import pyro_mcp.host_helpers as host_helpers
|
||||||
|
from pyro_mcp.host_helpers import (
|
||||||
|
DEFAULT_OPENCODE_CONFIG_PATH,
|
||||||
|
HostServerConfig,
|
||||||
|
_canonical_server_command,
|
||||||
|
_command_matches,
|
||||||
|
_repair_command,
|
||||||
|
connect_cli_host,
|
||||||
|
doctor_hosts,
|
||||||
|
print_or_write_opencode_config,
|
||||||
|
repair_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]:
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir(parents=True)
|
||||||
|
state_path = tmp_path / f"{name}-state.json"
|
||||||
|
script_path = bin_dir / name
|
||||||
|
script_path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
f"#!{sys.executable}",
|
||||||
|
"import json",
|
||||||
|
"import shlex",
|
||||||
|
"import sys",
|
||||||
|
f"STATE_PATH = {str(state_path)!r}",
|
||||||
|
"try:",
|
||||||
|
" with open(STATE_PATH, 'r', encoding='utf-8') as handle:",
|
||||||
|
" state = json.load(handle)",
|
||||||
|
"except FileNotFoundError:",
|
||||||
|
" state = {}",
|
||||||
|
"args = sys.argv[1:]",
|
||||||
|
"if args[:2] == ['mcp', 'add']:",
|
||||||
|
" name = args[2]",
|
||||||
|
" marker = args.index('--')",
|
||||||
|
" state[name] = args[marker + 1:]",
|
||||||
|
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
|
||||||
|
" json.dump(state, handle)",
|
||||||
|
" print(f'added {name}')",
|
||||||
|
" raise SystemExit(0)",
|
||||||
|
"if args[:2] == ['mcp', 'remove']:",
|
||||||
|
" name = args[2]",
|
||||||
|
" if name in state:",
|
||||||
|
" del state[name]",
|
||||||
|
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
|
||||||
|
" json.dump(state, handle)",
|
||||||
|
" print(f'removed {name}')",
|
||||||
|
" raise SystemExit(0)",
|
||||||
|
" print('not found', file=sys.stderr)",
|
||||||
|
" raise SystemExit(1)",
|
||||||
|
"if args[:2] == ['mcp', 'get']:",
|
||||||
|
" name = args[2]",
|
||||||
|
" if name not in state:",
|
||||||
|
" print('not found', file=sys.stderr)",
|
||||||
|
" raise SystemExit(1)",
|
||||||
|
" print(f'{name}: {shlex.join(state[name])}')",
|
||||||
|
" raise SystemExit(0)",
|
||||||
|
"if args[:2] == ['mcp', 'list']:",
|
||||||
|
" for item in sorted(state):",
|
||||||
|
" print(item)",
|
||||||
|
" raise SystemExit(0)",
|
||||||
|
"print('unsupported', file=sys.stderr)",
|
||||||
|
"raise SystemExit(2)",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
script_path.chmod(0o755)
|
||||||
|
return bin_dir, state_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_cli_host_replaces_existing_entry(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex")
|
||||||
|
state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8")
|
||||||
|
monkeypatch.setenv("PATH", str(bin_dir))
|
||||||
|
|
||||||
|
payload = connect_cli_host("codex", config=HostServerConfig())
|
||||||
|
|
||||||
|
assert payload["host"] == "codex"
|
||||||
|
assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||||
|
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
||||||
|
"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_server_command_validates_and_renders_variants() -> None:
|
||||||
|
assert _canonical_server_command(HostServerConfig(installed_package=True)) == [
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
]
|
||||||
|
assert _canonical_server_command(
|
||||||
|
HostServerConfig(profile="workspace-full", project_path="/repo")
|
||||||
|
) == [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
"--profile",
|
||||||
|
"workspace-full",
|
||||||
|
"--project-path",
|
||||||
|
"/repo",
|
||||||
|
]
|
||||||
|
assert _canonical_server_command(
|
||||||
|
HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main")
|
||||||
|
) == [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
"--repo-url",
|
||||||
|
"https://example.com/repo.git",
|
||||||
|
"--repo-ref",
|
||||||
|
"main",
|
||||||
|
]
|
||||||
|
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
"--no-project-source",
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||||
|
_canonical_server_command(
|
||||||
|
HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git")
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="cannot be combined"):
|
||||||
|
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
|
||||||
|
with pytest.raises(ValueError, match="requires --repo-url"):
|
||||||
|
_canonical_server_command(HostServerConfig(repo_ref="main"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
|
||||||
|
assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex"
|
||||||
|
assert _repair_command("codex", HostServerConfig(project_path="/repo")) == (
|
||||||
|
"pyro host repair codex --project-path /repo"
|
||||||
|
)
|
||||||
|
assert _repair_command(
|
||||||
|
"opencode",
|
||||||
|
HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"),
|
||||||
|
config_path=Path("/tmp/opencode.json"),
|
||||||
|
) == (
|
||||||
|
"pyro host repair opencode --installed-package --profile workspace-full "
|
||||||
|
"--repo-url file:///repo --config-path /tmp/opencode.json"
|
||||||
|
)
|
||||||
|
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
|
||||||
|
"pyro host repair codex --no-project-source"
|
||||||
|
)
|
||||||
|
assert _command_matches(
|
||||||
|
"pyro: uvx --from pyro-mcp pyro mcp serve",
|
||||||
|
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||||
|
)
|
||||||
|
assert _command_matches(
|
||||||
|
'"uvx --from pyro-mcp pyro mcp serve',
|
||||||
|
['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||||
|
)
|
||||||
|
assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_cli_host_reports_missing_cli_and_add_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValueError, match="unsupported CLI host"):
|
||||||
|
connect_cli_host("unsupported", config=HostServerConfig())
|
||||||
|
|
||||||
|
monkeypatch.setenv("PATH", "")
|
||||||
|
with pytest.raises(RuntimeError, match="codex CLI is not installed"):
|
||||||
|
connect_cli_host("codex", config=HostServerConfig())
|
||||||
|
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
script_path = bin_dir / "codex"
|
||||||
|
script_path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
f"#!{sys.executable}",
|
||||||
|
"import sys",
|
||||||
|
"raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
script_path.chmod(0o755)
|
||||||
|
monkeypatch.setenv("PATH", str(bin_dir))
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="codex mcp add failed"):
|
||||||
|
connect_cli_host("codex", config=HostServerConfig())
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_hosts_reports_ok_and_drifted(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex")
|
||||||
|
claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude")
|
||||||
|
combined_path = str(codex_bin) + ":" + str(claude_bin)
|
||||||
|
monkeypatch.setenv("PATH", combined_path)
|
||||||
|
|
||||||
|
codex_state.write_text(
|
||||||
|
json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
claude_state.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"pyro": [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
"--profile",
|
||||||
|
"workspace-full",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
opencode_config = tmp_path / "opencode.json"
|
||||||
|
opencode_config.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"mcp": {
|
||||||
|
"pyro": {
|
||||||
|
"type": "local",
|
||||||
|
"enabled": True,
|
||||||
|
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config)
|
||||||
|
by_host = {entry.host: entry for entry in entries}
|
||||||
|
|
||||||
|
assert by_host["codex"].status == "ok"
|
||||||
|
assert by_host["codex"].configured is True
|
||||||
|
assert by_host["claude-code"].status == "drifted"
|
||||||
|
assert by_host["claude-code"].configured is True
|
||||||
|
assert by_host["opencode"].status == "ok"
|
||||||
|
assert by_host["opencode"].configured is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("PATH", "")
|
||||||
|
config_path = tmp_path / "opencode.json"
|
||||||
|
|
||||||
|
config_path.write_text("[]", encoding="utf-8")
|
||||||
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||||
|
by_host = {entry.host: entry for entry in entries}
|
||||||
|
assert by_host["opencode"].status == "unavailable"
|
||||||
|
assert "JSON object" in by_host["opencode"].details
|
||||||
|
|
||||||
|
config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8")
|
||||||
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||||
|
by_host = {entry.host: entry for entry in entries}
|
||||||
|
assert by_host["opencode"].status == "unavailable"
|
||||||
|
assert "missing mcp.pyro" in by_host["opencode"].details
|
||||||
|
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||||
|
by_host = {entry.host: entry for entry in entries}
|
||||||
|
assert by_host["opencode"].status == "drifted"
|
||||||
|
assert by_host["opencode"].configured is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_hosts_reports_invalid_json_for_installed_opencode(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config_path = tmp_path / "opencode.json"
|
||||||
|
config_path.write_text("{invalid", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
shutil,
|
||||||
|
"which",
|
||||||
|
lambda name: "/usr/bin/opencode" if name == "opencode" else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
||||||
|
by_host = {entry.host: entry for entry in entries}
|
||||||
|
|
||||||
|
assert by_host["opencode"].status == "drifted"
|
||||||
|
assert "invalid JSON" in by_host["opencode"].details
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_opencode_preserves_unrelated_keys(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("PATH", "")
|
||||||
|
config_path = tmp_path / "opencode.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
||||||
|
|
||||||
|
assert payload["config_path"] == str(config_path.resolve())
|
||||||
|
repaired = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert repaired["theme"] == "light"
|
||||||
|
assert repaired["mcp"]["other"] == {"type": "local"}
|
||||||
|
assert repaired["mcp"]["pyro"] == {
|
||||||
|
"type": "local",
|
||||||
|
"enabled": True,
|
||||||
|
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_opencode_backs_up_non_object_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("PATH", "")
|
||||||
|
config_path = tmp_path / "opencode.json"
|
||||||
|
config_path.write_text("[]", encoding="utf-8")
|
||||||
|
|
||||||
|
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
||||||
|
|
||||||
|
backup_path = Path(str(payload["backup_path"]))
|
||||||
|
assert backup_path.exists()
|
||||||
|
assert backup_path.read_text(encoding="utf-8") == "[]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_opencode_backs_up_invalid_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("PATH", "")
|
||||||
|
config_path = tmp_path / "opencode.json"
|
||||||
|
config_path.write_text("{invalid", encoding="utf-8")
|
||||||
|
|
||||||
|
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
||||||
|
|
||||||
|
backup_path = Path(str(payload["backup_path"]))
|
||||||
|
assert backup_path.exists()
|
||||||
|
assert backup_path.read_text(encoding="utf-8") == "{invalid"
|
||||||
|
repaired = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
assert repaired["mcp"]["pyro"]["command"] == [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None:
|
||||||
|
output_path = tmp_path / "opencode.json"
|
||||||
|
payload = print_or_write_opencode_config(
|
||||||
|
config=HostServerConfig(project_path="/repo"),
|
||||||
|
output_path=output_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["output_path"] == str(output_path)
|
||||||
|
rendered = json.loads(output_path.read_text(encoding="utf-8"))
|
||||||
|
assert rendered == {
|
||||||
|
"mcp": {
|
||||||
|
"pyro": {
|
||||||
|
"type": "local",
|
||||||
|
"enabled": True,
|
||||||
|
"command": [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
"--project-path",
|
||||||
|
"/repo",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_or_write_opencode_config_returns_rendered_text() -> None:
|
||||||
|
payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run"))
|
||||||
|
|
||||||
|
assert payload["host"] == "opencode"
|
||||||
|
assert payload["server_command"] == [
|
||||||
|
"uvx",
|
||||||
|
"--from",
|
||||||
|
"pyro-mcp",
|
||||||
|
"pyro",
|
||||||
|
"mcp",
|
||||||
|
"serve",
|
||||||
|
"--profile",
|
||||||
|
"vm-run",
|
||||||
|
]
|
||||||
|
rendered = str(payload["rendered_config"])
|
||||||
|
assert '"type": "local"' in rendered
|
||||||
|
assert '"command": [' in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_doctor_reports_opencode_missing_when_config_absent(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("PATH", "")
|
||||||
|
|
||||||
|
entries = doctor_hosts(
|
||||||
|
config=HostServerConfig(),
|
||||||
|
config_path=tmp_path / "missing-opencode.json",
|
||||||
|
)
|
||||||
|
by_host = {entry.host: entry for entry in entries}
|
||||||
|
|
||||||
|
assert by_host["opencode"].status == "unavailable"
|
||||||
|
assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
host_helpers,
|
||||||
|
"connect_cli_host",
|
||||||
|
lambda host, *, config: {"host": host, "profile": config.profile},
|
||||||
|
)
|
||||||
|
assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == {
|
||||||
|
"host": "codex",
|
||||||
|
"profile": "vm-run",
|
||||||
|
}
|
||||||
|
|
||||||
|
commands: list[list[str]] = []
|
||||||
|
|
||||||
|
def _fake_run_command(command: list[str]) -> CompletedProcess[str]:
|
||||||
|
commands.append(command)
|
||||||
|
if command[:3] == ["codex", "mcp", "get"]:
|
||||||
|
return CompletedProcess(command, 1, "", "not found")
|
||||||
|
if command[:3] == ["codex", "mcp", "list"]:
|
||||||
|
return CompletedProcess(command, 0, "pyro\n", "")
|
||||||
|
raise AssertionError(command)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
shutil,
|
||||||
|
"which",
|
||||||
|
lambda name: "/usr/bin/codex" if name == "codex" else None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command)
|
||||||
|
|
||||||
|
entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig())
|
||||||
|
assert entry.status == "drifted"
|
||||||
|
assert entry.configured is True
|
||||||
|
assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
2
uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue