From 999fe1b23a2d2bf0081478d56885fbc40fa60b8c Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 15:03:20 -0300 Subject: [PATCH 1/8] Reframe pyro around the chat-host path Make the docs and help text unapologetically teach as the product path for Claude Code, Codex, and OpenCode on Linux KVM. Rewrite the README, install/first-run/integration guides, public contract, vision, and use-case docs around the zero-to-hero chat flow, and explicitly note that there are no users yet so breaking changes are acceptable while the interface is still being shaped. Update package metadata, CLI help, and the docs/help expectation tests to match the new positioning. Validate the reframe with usage: pyro [-h] [--version] COMMAND ... Validate the host and serve disposable MCP workspaces for chat-based coding agents on supported Linux x86_64 KVM hosts. positional arguments: COMMAND env Inspect and manage curated environments. mcp Run the MCP server. run Run one command inside an ephemeral VM. workspace Manage persistent workspaces. doctor Inspect runtime and host diagnostics. demo Run built-in demos. options: -h, --help show this help message and exit --version show program's version number and exit Suggested zero-to-hero path: pyro doctor pyro env list pyro env pull debian:12 pyro run debian:12 -- git --version pyro mcp serve Connect a chat host after that: claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes pyro workspace exec WORKSPACE_ID -- cat note.txt pyro workspace diff WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace shell open WORKSPACE_ID --id-only pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace export WORKSPACE_ID note.txt --output ./note.txt, usage: pyro mcp serve [-h] [--profile {vm-run,workspace-core,workspace-full}] Expose pyro tools over stdio for an MCP client. Bare `pyro mcp serve` now starts `workspace-core`, the recommended first profile for most chat hosts. options: -h, --help show this help message and exit --profile {vm-run,workspace-core,workspace-full} Expose only one model-facing tool profile. `workspace- core` is the default and recommended first profile for most chat hosts; `workspace-full` is the larger opt-in profile. (default: workspace-core) Default and recommended first start: pyro mcp serve Profiles: workspace-core: default for normal persistent chat editing vm-run: smallest one-shot-only surface workspace-full: larger opt-in surface for shells, services, snapshots, secrets, network policy, and disk tools Use --profile workspace-full only when the host truly needs those extra workspace capabilities., and uv run ruff check . All checks passed! uv run mypy Success: no issues found in 61 source files uv run pytest -n auto ============================= test session starts ============================== platform linux -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0 rootdir: /home/thales/projects/personal/pyro configfile: pyproject.toml testpaths: tests plugins: anyio-4.12.1, xdist-3.8.0, cov-7.0.0 created: 32/32 workers 32 workers [393 items] ........................................................................ [ 18%] ........................................................................ [ 36%] ........................................................................ [ 54%] ........................................................................ [ 73%] ........................................................................ [ 91%] ................................. [100%] =============================== warnings summary =============================== ../../../.local/share/uv/python/cpython-3.12.10-linux-x86_64-gnu/lib/python3.12/importlib/metadata/__init__.py:467: 32 warnings /home/thales/.local/share/uv/python/cpython-3.12.10-linux-x86_64-gnu/lib/python3.12/importlib/metadata/__init__.py:467: DeprecationWarning: Implicit None on return values is deprecated and will raise KeyErrors. return self.metadata['Version'] -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html ================================ tests coverage ================================ _______________ coverage: platform linux, python 3.12.10-final-0 _______________ Name Stmts Miss Cover Missing ------------------------------------------------------------------------- src/pyro_mcp/__init__.py 25 0 100% src/pyro_mcp/api.py 307 7 98% 37-38, 63, 69, 72, 75, 548 src/pyro_mcp/cli.py 1132 141 88% 288-289, 332-333, 336, 344, 367-368, 394-395, 398, 406, 450, 460-461, 464, 477, 483-484, 498-499, 502, 566-575, 592-593, 596, 635, 2180, 2182, 2226, 2236, 2280, 2284-2285, 2295, 2302, 2344-2351, 2392, 2409-2414, 2459-2461, 2470-2472, 2483-2485, 2494-2496, 2503-2505, 2510-2512, 2523-2528, 2530, 2541-2546, 2567-2572, 2574, 2589-2594, 2596, 2608, 2623, 2637, 2655-2660, 2669-2674, 2676, 2683-2688, 2690, 2701-2706, 2708, 2719-2724, 2726, 2737-2742, 2764, 2787, 2806, 2824, 2841, 2899, 3017 src/pyro_mcp/contract.py 52 0 100% src/pyro_mcp/demo.py 16 0 100% src/pyro_mcp/doctor.py 12 0 100% src/pyro_mcp/ollama_demo.py 245 6 98% 289, 294, 299, 318, 439, 550 src/pyro_mcp/runtime.py 142 14 90% 80, 84, 88, 92, 120, 130, 144, 173, 182, 194, 230-232, 262 src/pyro_mcp/runtime_boot_check.py 33 0 100% src/pyro_mcp/runtime_build.py 546 47 91% 92, 127, 181, 189, 238-240, 263-265, 300, 325, 331, 340-341, 343, 392, 396, 413, 416, 492-494, 497-499, 522, 525, 578, 615, 620, 646-647, 649, 686, 688, 694, 697, 725, 765, 779, 791, 805, 808, 1002, 1009, 1198 src/pyro_mcp/runtime_bundle/__init__.py 0 0 100% src/pyro_mcp/runtime_network_check.py 15 0 100% src/pyro_mcp/server.py 8 0 100% src/pyro_mcp/vm_environments.py 386 55 86% 128, 131, 267, 274, 281, 304-306, 329-331, 352-353, 355, 380, 382, 392-394, 415, 418, 421, 429, 431, 436-437, 446-448, 488, 495-496, 502, 515, 526, 539, 546, 549, 570, 596, 599, 608-609, 613, 617, 626, 629, 636, 644, 647, 659, 667, 676, 682, 685 src/pyro_mcp/vm_firecracker.py 47 0 100% src/pyro_mcp/vm_guest.py 206 22 89% 139, 142, 173, 176, 202, 205, 208, 211, 217, 239, 262-279, 291, 313, 633-634, 643 src/pyro_mcp/vm_manager.py 2846 355 88% 625, 642, 650-657, 677, 684, 688, 712-715, 795-796, 818, 828, 830, 845, 853-855, 858, 870, 872, 881, 889, 892, 901-902, 910, 913, 919, 926, 929, 933, 951-955, 1010-1011, 1050, 1096, 1102, 1114, 1150, 1156, 1159, 1168, 1170, 1173-1177, 1230, 1236, 1239, 1248, 1250, 1253-1257, 1268, 1277, 1280, 1284-1290, 1319, 1322-1324, 1326, 1333, 1335, 1345, 1347, 1349, 1352-1353, 1361, 1377, 1379, 1381, 1391, 1403-1404, 1408, 1424, 1440-1441, 1443, 1447, 1450-1451, 1469, 1476, 1488, 1505, 1508-1509, 1511, 1582-1583, 1586-1588, 1599, 1602, 1605, 1617, 1638, 1649-1650, 1657-1658, 1669-1671, 1781, 1792-1798, 1808, 1860, 1870, 1891, 1894-1895, 1901-1904, 1910, 1922-1962, 1991-1993, 2034, 2046-2047, 2077, 2146, 2175, 2524-2528, 2598-2602, 2614, 2720, 3563, 3577, 3580, 3583, 3648-3653, 3720, 3802, 3842-3843, 3846-3847, 3862-3863, 3914, 4194, 4229, 4232, 4237, 4250, 4254, 4263, 4277, 4316, 4349, 4444, 4472-4473, 4477-4478, 4504, 4530-4531, 4576, 4578, 4600-4601, 4629, 4631, 4661-4662, 4681-4682, 4734, 4738, 4741-4743, 4745, 4747, 4776-4777, 4809-4845, 4863-4864, 4903, 4905, 4934, 4954-4955, 4977, 4988-4990, 5036, 5049-5050, 5059-5061, 5104-5105, 5171-5178, 5189-5192, 5203, 5208, 5216-5230, 5240, 5473-5476, 5485-5490, 5498-5503, 5513, 5557, 5577, 5601-5602, 5678-5680, 5706-5725, 5784, 5789, 5804, 5832, 5836, 5848, 5884-5886, 5946, 5950, 6079, 6111, 6155, 6170, 6189, 6201, 6242, 6251, 6256, 6269, 6274, 6296, 6394, 6422-6423 src/pyro_mcp/vm_network.py 134 22 84% 65-66, 139, 201, 203, 205, 226, 317-331, 350-351, 360, 362, 372-384 src/pyro_mcp/workspace_disk.py 164 0 100% src/pyro_mcp/workspace_files.py 293 0 100% src/pyro_mcp/workspace_ports.py 79 1 99% 116 src/pyro_mcp/workspace_shell_output.py 88 2 98% 16, 61 src/pyro_mcp/workspace_shells.py 235 26 89% 105-118, 193-194, 226-227, 230-235, 251, 257-259, 263, 270-271, 299, 301, 303, 306-307 src/pyro_mcp/workspace_use_case_smokes.py 216 8 96% 131, 134-135, 423-426, 490 ------------------------------------------------------------------------- TOTAL 7227 706 90% Required test coverage of 90% reached. Total coverage: 90.23% ======================= 393 passed, 32 warnings in 5.60s =======================. --- README.md | 615 +++---------------- docs/first-run.md | 306 +++------ docs/install.md | 296 +++------ docs/integrations.md | 221 +++---- docs/public-contract.md | 439 ++++--------- docs/use-cases/README.md | 2 +- docs/use-cases/cold-start-repo-validation.md | 26 +- docs/use-cases/parallel-workspaces.md | 32 +- docs/use-cases/repro-fix-loop.md | 32 +- docs/use-cases/review-eval-workflows.md | 30 +- docs/use-cases/untrusted-inspection.md | 23 +- docs/vision.md | 135 ++-- pyproject.toml | 4 +- src/pyro_mcp/cli.py | 31 +- tests/test_cli.py | 29 +- 15 files changed, 608 insertions(+), 1613 deletions(-) diff --git a/README.md b/README.md index 793a7b3..65e2dfc 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,50 @@ # pyro-mcp -`pyro-mcp` is a stable agent workspace product for one-shot commands and persistent work inside ephemeral Firecracker microVMs using curated Linux environments such as `debian:12`. +`pyro-mcp` is a disposable MCP workspace for chat-based coding agents such as +Claude Code, Codex, and OpenCode. + +It is built for Linux `x86_64` hosts with working KVM. The product path is: + +1. prove the host works +2. connect a chat host over MCP +3. let the agent work inside a disposable workspace +4. validate the workflow with the recipe-backed smoke pack + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +This repo is not trying to be a generic VM toolkit, a CI runner, or an +SDK-first platform. [![PyPI version](https://img.shields.io/pypi/v/pyro-mcp.svg)](https://pypi.org/project/pyro-mcp/) -This is for coding agents, MCP clients, and developers who want isolated command execution and stable disposable workspaces in ephemeral microVMs. - -It exposes the same runtime in three public forms: - -- the `pyro` CLI -- the Python SDK via `from pyro_mcp import Pyro` -- an MCP server so LLM clients can call VM tools directly - ## Start Here -- Install: [docs/install.md](docs/install.md) -- Vision: [docs/vision.md](docs/vision.md) -- Workspace GA roadmap: [docs/roadmap/task-workspace-ga.md](docs/roadmap/task-workspace-ga.md) -- LLM chat roadmap: [docs/roadmap/llm-chat-ergonomics.md](docs/roadmap/llm-chat-ergonomics.md) -- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md) +- Install and zero-to-hero path: [docs/install.md](docs/install.md) - First run transcript: [docs/first-run.md](docs/first-run.md) +- Chat host integrations: [docs/integrations.md](docs/integrations.md) +- Use-case recipes: [docs/use-cases/README.md](docs/use-cases/README.md) +- Vision: [docs/vision.md](docs/vision.md) +- Public contract: [docs/public-contract.md](docs/public-contract.md) +- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) +- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) - What's new in 4.0.0: [CHANGELOG.md#400](CHANGELOG.md#400) -- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) -- Integration targets: [docs/integrations.md](docs/integrations.md) -- Public contract: [docs/public-contract.md](docs/public-contract.md) -- Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) -- Changelog: [CHANGELOG.md](CHANGELOG.md) +- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) + +## Who It's For + +- Claude Code users who want disposable workspaces instead of running directly + on the host +- Codex users who want an MCP-backed sandbox for repo setup, bug fixing, and + evaluation loops +- OpenCode users who want the same disposable workspace model +- people evaluating repo setup, test, and app-start workflows from a chat + interface on a clean machine + +If you want a general VM platform, a queueing system, or a broad SDK product, +this repo is intentionally biased away from that story. ## Quickstart @@ -73,84 +89,15 @@ Pulled: debian:12 git version ... ``` -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. - -## Stable Workspace Path - -`pyro run` is the stable one-shot entrypoint. `pyro workspace ...` is the stable path when an -agent needs one sandbox to stay alive across repeated commands, shells, services, checkpoints, -diffs, exports, and reset. - -After that stable walkthrough works, continue with the recipe set in -[docs/use-cases/README.md](docs/use-cases/README.md). It packages the five core workspace stories -into documented flows plus real guest-backed smoke targets such as `make smoke-use-cases` and -`make smoke-repro-fix-loop`. At this point `make smoke-use-cases` is the -trustworthy guest-backed release-gate path for the advertised workspace workflows. - -The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...` -for the published package, or `uv run pyro ...` from a source checkout. - -```bash -uv tool install pyro-mcp -WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" -pyro workspace list -pyro workspace update "$WORKSPACE_ID" --label owner=codex -pyro workspace sync push "$WORKSPACE_ID" ./changes -pyro workspace file read "$WORKSPACE_ID" note.txt --content-only -pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch -pyro workspace exec "$WORKSPACE_ID" -- cat note.txt -pyro workspace snapshot create "$WORKSPACE_ID" checkpoint -pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' -pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint -pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt -pyro workspace delete "$WORKSPACE_ID" -``` - -![Stable workspace walkthrough](docs/assets/workspace-first-run.gif) - -That stable workspace path gives you: - -- initial host-in seeding with `--seed-path` -- discovery metadata with `--name`, `--label`, `workspace list`, and `workspace update` -- later host-in updates with `workspace sync push` -- model-native file inspection and text edits with `workspace file *` and `workspace patch apply` -- one-shot commands with `workspace exec` and persistent PTYs with `workspace shell *` -- long-running processes with `workspace service *` -- explicit checkpoints with `workspace snapshot *` -- full-sandbox recovery with `workspace reset` -- baseline comparison with `workspace diff` -- explicit host-out export with `workspace export` -- secondary stopped-workspace disk inspection with `workspace stop|start` and `workspace disk *` - -After the quickstart works: - -- prove the full one-shot lifecycle with `uvx --from pyro-mcp pyro demo` -- start most chat hosts with `uvx --from pyro-mcp pyro mcp serve` -- create a persistent workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo` -- add a human-friendly workspace name with `uvx --from pyro-mcp pyro workspace create debian:12 --name repro-fix --label issue=123` -- rediscover or retag workspaces with `uvx --from pyro-mcp pyro workspace list` and `uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex` -- update a live workspace from the host with `uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes` -- enable outbound guest networking for one workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress` -- add literal or file-backed secrets with `uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` -- map one persisted secret into one exec, shell, or service call with `--secret-env API_TOKEN` -- inspect and edit files without shell quoting with `uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` -- diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID` -- capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint` -- reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint` -- export a changed file or directory with `uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` -- open a persistent interactive shell with `uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --id-only` -- start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` -- publish one guest service port to the host with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports` and `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` -- stop a workspace for offline inspection with `uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID` -- inspect or export one stopped guest rootfs with `uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID`, `uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` -- move to Python or MCP via [docs/integrations.md](docs/integrations.md) +The first pull downloads an OCI environment from public Docker Hub, requires +outbound HTTPS access to `registry-1.docker.io`, and needs local cache space +for the guest image. ## Chat Host Quickstart -For most MCP chat hosts, bare `pyro mcp serve` now starts `workspace-core`. It exposes the practical -persistent editing loop without shells, services, snapshots, secrets, network -policy, or disk tools. +After the quickstart works, the intended next step is to connect a chat host. +Bare `pyro mcp serve` starts `workspace-core`, which is the default product +path. ```bash uvx --from pyro-mcp pyro mcp serve @@ -189,223 +136,55 @@ OpenCode `opencode.json` snippet: } ``` -If `pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro` -command with `pyro` in the same host-specific command or config shape. Use -`--profile workspace-full` only when the host truly needs the full advanced -workspace surface. +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same command or config shape. -Profile progression: +Use `--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. -- `workspace-core`: default and recommended first profile for normal persistent chat editing -- `vm-run`: smallest one-shot-only surface -- `workspace-full`: explicit advanced opt-in when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools +## Zero To Hero -## Supported Hosts +1. Validate the host with `pyro doctor`. +2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. +3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve`. +4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). + `repro-fix-loop` is the shortest chat-first story. +5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path + for the advertised workflows. -Supported today: +That is the intended user journey. The terminal commands exist to validate and +debug that chat-host path, not to replace it as the main product story. -- Linux x86_64 -- Python 3.12+ -- `uv` -- `/dev/kvm` +## Manual Terminal Workspace Flow -Optional for outbound guest networking: - -- `ip` -- `nft` or `iptables` -- privilege to create TAP devices and configure NAT - -Not supported today: - -- macOS -- Windows -- Linux hosts without working KVM at `/dev/kvm` - -## Detailed Walkthrough - -If you want the expanded version of the canonical quickstart, use the step-by-step flow below. - -### 1. Check the host +If you want to understand what the agent gets inside the sandbox, or debug a +recipe outside the chat host, use the terminal companion flow below: ```bash -uvx --from pyro-mcp pyro doctor +uv tool install pyro-mcp +WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" +pyro workspace list +pyro workspace sync push "$WORKSPACE_ID" ./changes +pyro workspace file read "$WORKSPACE_ID" note.txt --content-only +pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch +pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace snapshot create "$WORKSPACE_ID" checkpoint +pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint +pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt +pyro workspace delete "$WORKSPACE_ID" ``` -Expected success signals: +Add `workspace-full` only when the chat or your manual debugging loop really +needs: -```bash -Platform: linux-x86_64 -Runtime: PASS -KVM: exists=yes readable=yes writable=yes -Environment cache: /home/you/.cache/pyro-mcp/environments -Capabilities: vm_boot=yes guest_exec=yes guest_network=yes -Networking: tun=yes ip_forward=yes -``` +- persistent PTY shells +- long-running services and readiness probes +- guest networking and published ports +- secrets +- stopped-workspace disk inspection -### 2. Inspect the catalog - -```bash -uvx --from pyro-mcp pyro env list -``` - -Expected output: - -```bash -Catalog version: 4.0.0 -debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. -debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. -debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. -``` - -### 3. Pull the default environment - -```bash -uvx --from pyro-mcp pyro env pull debian:12 -``` - -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. -See [docs/host-requirements.md](docs/host-requirements.md) for the full host requirements. - -### 4. Run one command in a guest - -```bash -uvx --from pyro-mcp pyro run debian:12 -- git --version -``` - -Expected success signals: - -```bash -[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... -git version ... -``` - -The guest command output and the `[run] ...` summary are written to different streams, so they -may appear in either order in terminals or capture tools. Use `--json` if you need a -deterministic structured result. - -### 5. Optional demos - -```bash -uvx --from pyro-mcp pyro demo -uvx --from pyro-mcp pyro demo --network -``` - -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. - -Example output: - -```json -{ - "cleanup": { - "deleted": true, - "reason": "post_exec_cleanup", - "vm_id": "..." - }, - "command": "git --version", - "environment": "debian:12", - "execution_mode": "guest_vsock", - "exit_code": 0, - "stdout": "git version ...\n" -} -``` - -When you are done evaluating and want to remove stale cached environments, run `pyro env prune`. - -If you prefer a fuller copy-pasteable transcript, see [docs/first-run.md](docs/first-run.md). -The walkthrough GIF above was rendered from [docs/assets/first-run.tape](docs/assets/first-run.tape) using [scripts/render_tape.sh](scripts/render_tape.sh). - -## Stable Workspaces - -Use `pyro run` for one-shot commands. Use `pyro workspace ...` when you need repeated commands in one -workspace without recreating the sandbox every time. - -The project direction is an agent workspace, not a CI job runner. Persistent -workspaces are meant to let an agent stay inside one bounded sandbox across multiple -steps. See [docs/vision.md](docs/vision.md) for the product thesis and the -longer-term interaction model. - -```bash -pyro workspace create debian:12 --seed-path ./repo -pyro workspace create debian:12 --network-policy egress -pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected -pyro workspace create debian:12 --network-policy egress+published-ports -pyro workspace sync push WORKSPACE_ID ./changes --dest src -pyro workspace file list WORKSPACE_ID src --recursive -pyro workspace file read WORKSPACE_ID src/note.txt --content-only -pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py -pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch -pyro workspace exec WORKSPACE_ID -- cat src/note.txt -pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -pyro workspace diff WORKSPACE_ID -pyro workspace snapshot create WORKSPACE_ID checkpoint -pyro workspace reset WORKSPACE_ID --snapshot checkpoint -pyro workspace reset WORKSPACE_ID -pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' -pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 -pyro workspace shell close WORKSPACE_ID SHELL_ID -pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' -pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' -pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -pyro workspace service list WORKSPACE_ID -pyro workspace service status WORKSPACE_ID web -pyro workspace service logs WORKSPACE_ID web --tail-lines 50 -pyro workspace service stop WORKSPACE_ID web -pyro workspace service stop WORKSPACE_ID worker -pyro workspace stop WORKSPACE_ID -pyro workspace disk list WORKSPACE_ID -pyro workspace disk read WORKSPACE_ID src/note.txt --content-only -pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 -pyro workspace start WORKSPACE_ID -pyro workspace logs WORKSPACE_ID -pyro workspace delete WORKSPACE_ID -``` - -Persistent workspaces start in `/workspace` and keep command history until you delete them. For -machine consumption, use `--id-only` for only the identifier or `--json` for the full -workspace payload. Use `--seed-path` when -you want the workspace to start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` -archive instead of an empty workspace. Use `pyro workspace sync push` when you want to import -later host-side changes into a started workspace. Sync is non-atomic in `4.0.0`; if it fails -partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. -Use `pyro workspace diff` to compare the live `/workspace` tree to its immutable create-time -baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use -`pyro workspace snapshot *` and `pyro workspace reset` when you want explicit checkpoints and -full-sandbox recovery. Use `pyro workspace exec` for one-shot -non-interactive commands inside a live workspace, and `pyro workspace shell *` when you need a -persistent PTY session that keeps interactive shell state between calls. Prefer -`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell reads. Use -`pyro workspace service *` when the workspace needs one or more long-running background processes. -Typed readiness checks prefer `--ready-file`, `--ready-tcp`, or `--ready-http`; keep -`--ready-command` as the escape hatch. Service metadata and logs live outside `/workspace`, so the -internal service state does not appear in `pyro workspace diff` or `pyro workspace export`. -Use `--network-policy egress` when the workspace needs outbound guest networking, and -`--network-policy egress+published-ports` plus `workspace service start --publish` when one -service must be probed from the host on `127.0.0.1`. -Use `--secret` and `--secret-file` at workspace creation when the sandbox needs private tokens or -config. Persisted secrets are materialized inside the guest at `/run/pyro-secrets/`, and -`--secret-env SECRET_NAME[=ENV_VAR]` maps one secret into one exec, shell, or service call without -exposing the raw value in workspace status, logs, diffs, or exports. Use `pyro workspace stop` -plus `pyro workspace disk list|read|export` when you need offline inspection or one raw ext4 copy -from a stopped guest-backed workspace, then `pyro workspace start` to resume the same workspace. - -## Public Interfaces - -The public user-facing interface is `pyro` and `Pyro`. After the CLI validation path works, you can choose one of three surfaces: - -- `pyro` for direct CLI usage, including one-shot `run` and persistent `workspace` workflows -- `from pyro_mcp import Pyro` for Python orchestration -- `pyro mcp serve` for MCP clients - -Command forms: - -- published package without install: `uvx --from pyro-mcp pyro ...` -- installed package: `pyro ...` -- source checkout: `uv run pyro ...` - -`Makefile` targets are contributor conveniences for this repository and are not the primary product UX. +The five recipe docs show when those capabilities are justified: +[docs/use-cases/README.md](docs/use-cases/README.md) ## Official Environments @@ -415,216 +194,10 @@ Current official environments in the shipped catalog: - `debian:12-base` - `debian:12-build` -The package ships the embedded Firecracker runtime and a package-controlled environment catalog. -Official environments are pulled as OCI artifacts from public Docker Hub repositories into a local -cache on first use or through `pyro env pull`. -End users do not need registry credentials to pull or run official environments. -The default cache location is `~/.cache/pyro-mcp/environments`; override it with -`PYRO_ENVIRONMENT_CACHE_DIR`. - -## CLI - -List available environments: - -```bash -pyro env list -``` - -Prefetch one environment: - -```bash -pyro env pull debian:12 -``` - -Run one command in an ephemeral VM: - -```bash -pyro run debian:12 -- git --version -``` - -Run with outbound internet enabled: - -```bash -pyro run debian:12 --network -- \ - 'python3 -c "import urllib.request; print(urllib.request.urlopen(\"https://example.com\", timeout=10).status)"' -``` - -Show runtime and host diagnostics: - -```bash -pyro doctor -pyro doctor --json -``` - -`pyro run` defaults to `1 vCPU / 1024 MiB`. -It fails closed when guest boot or guest exec is unavailable. -Use `--allow-host-compat` only if you explicitly want host execution. - -Run the MCP server after the CLI path above works. Start most chat hosts with -`workspace-core`: - -```bash -pyro mcp serve -``` - -Profile progression for chat hosts: - -- `workspace-core`: recommended first profile for normal persistent chat editing -- `vm-run`: expose only `vm_run` for one-shot-only hosts -- `workspace-full`: expose shells, services, snapshots, secrets, network policy, and disk tools when the chat truly needs the full stable surface - -Run the deterministic demo: - -```bash -pyro demo -pyro demo --network -``` - -Run the Ollama demo: - -```bash -ollama serve -ollama pull llama3.2:3b -pyro demo ollama -``` - -## Python SDK - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -result = pyro.run_in_vm( - environment="debian:12", - command="git --version", - timeout_seconds=30, - network=False, -) -print(result["stdout"]) -``` - -Lower-level lifecycle control remains available: - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_vm( - environment="debian:12", - ttl_seconds=600, - network=True, -) -vm_id = created["vm_id"] -pyro.start_vm(vm_id) -result = pyro.exec_vm(vm_id, command="git --version", timeout_seconds=30) -print(result["stdout"]) -``` - -`exec_vm()` is a one-command auto-cleaning call. After it returns, the VM is already deleted. - -Environment management is also available through the SDK: - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -print(pyro.list_environments()) -print(pyro.inspect_environment("debian:12")) -``` - -For repeated commands in one workspace: - -```python -from pyro_mcp import Pyro - -pyro = Pyro() -workspace = pyro.create_workspace(environment="debian:12", seed_path="./repo") -workspace_id = workspace["workspace_id"] -try: - pyro.push_workspace_sync(workspace_id, "./changes", dest="src") - result = pyro.exec_workspace(workspace_id, command="cat src/note.txt") - print(result["stdout"], end="") -finally: - pyro.delete_workspace(workspace_id) -``` - -## MCP Tools - -Primary agent-facing tool: - -- `vm_run(environment, command, vcpu_count=1, mem_mib=1024, timeout_seconds=30, ttl_seconds=600, network=false, allow_host_compat=false)` - -Advanced lifecycle tools: - -- `vm_list_environments()` -- `vm_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network=false, allow_host_compat=false)` -- `vm_start(vm_id)` -- `vm_exec(vm_id, command, timeout_seconds=30)` auto-cleans the VM after that command -- `vm_stop(vm_id)` -- `vm_delete(vm_id)` -- `vm_status(vm_id)` -- `vm_network_info(vm_id)` -- `vm_reap_expired()` - -Persistent workspace tools: - -- `workspace_create(environment, vcpu_count=1, mem_mib=1024, ttl_seconds=600, network_policy="off", allow_host_compat=false, seed_path=null, secrets=null)` -- `workspace_sync_push(workspace_id, source_path, dest="/workspace")` -- `workspace_exec(workspace_id, command, timeout_seconds=30, secret_env=null)` -- `workspace_export(workspace_id, path, output_path)` -- `workspace_diff(workspace_id)` -- `snapshot_create(workspace_id, snapshot_name)` -- `snapshot_list(workspace_id)` -- `snapshot_delete(workspace_id, snapshot_name)` -- `workspace_reset(workspace_id, snapshot="baseline")` -- `service_start(workspace_id, service_name, command, cwd="/workspace", readiness=null, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=null, published_ports=null)` -- `service_list(workspace_id)` -- `service_status(workspace_id, service_name)` -- `service_logs(workspace_id, service_name, tail_lines=200)` -- `service_stop(workspace_id, service_name)` -- `shell_open(workspace_id, cwd="/workspace", cols=120, rows=30, secret_env=null)` -- `shell_read(workspace_id, shell_id, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)` -- `shell_write(workspace_id, shell_id, input, append_newline=true)` -- `shell_signal(workspace_id, shell_id, signal_name="INT")` -- `shell_close(workspace_id, shell_id)` -- `workspace_status(workspace_id)` -- `workspace_logs(workspace_id)` -- `workspace_delete(workspace_id)` - -Recommended MCP tool profiles: - -- `vm-run`: `vm_run` only -- `workspace-core`: `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, `workspace_delete` -- `workspace-full`: the complete stable MCP surface above - -## Integration Examples - -- Python one-shot SDK example: [examples/python_run.py](examples/python_run.py) -- Python lifecycle example: [examples/python_lifecycle.py](examples/python_lifecycle.py) -- Python workspace example: [examples/python_workspace.py](examples/python_workspace.py) -- Claude Code MCP setup: [examples/claude_code_mcp.md](examples/claude_code_mcp.md) -- Codex MCP setup: [examples/codex_mcp.md](examples/codex_mcp.md) -- OpenCode MCP config: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json) -- Generic MCP client config: [examples/mcp_client_config.md](examples/mcp_client_config.md) -- Claude Desktop MCP config: [examples/claude_desktop_mcp_config.json](examples/claude_desktop_mcp_config.json) -- Cursor MCP config: [examples/cursor_mcp_config.json](examples/cursor_mcp_config.json) -- OpenAI Responses API example: [examples/openai_responses_vm_run.py](examples/openai_responses_vm_run.py) -- OpenAI Responses `workspace-core` example: [examples/openai_responses_workspace_core.py](examples/openai_responses_workspace_core.py) -- LangChain wrapper example: [examples/langchain_vm_run.py](examples/langchain_vm_run.py) -- Agent-ready `vm_run` example: [examples/agent_vm_run.py](examples/agent_vm_run.py) - -## Runtime - -The package ships an embedded Linux x86_64 runtime payload with: - -- Firecracker -- Jailer -- guest agent -- runtime manifest and diagnostics - -No system Firecracker installation is required. -`pyro` installs curated environments into a local cache and reports their status through `pyro env inspect` and `pyro doctor`. -The public CLI is human-readable by default; add `--json` for structured output. +The embedded Firecracker runtime ships with the package. Official environments +are pulled as OCI artifacts from public Docker Hub into a local cache on first +use or through `pyro env pull`. End users do not need registry credentials to +pull or run the official environments. ## Contributor Workflow @@ -637,11 +210,12 @@ make check make dist-check ``` -Contributor runtime sources live under `runtime_sources/`. The packaged runtime bundle under -`src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime assets plus manifest metadata; -end-user environment installs pull OCI-published environments by default. Use -`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly validating a locally -built contributor runtime bundle. +Contributor runtime sources live under `runtime_sources/`. The packaged runtime +bundle under `src/pyro_mcp/runtime_bundle/` contains the embedded boot/runtime +assets plus manifest metadata. End-user environment installs pull +OCI-published environments by default. Use +`PYRO_RUNTIME_BUNDLE_DIR=build/runtime_bundle` only when you are explicitly +validating a locally built contributor runtime bundle. Official environment publication is performed locally against Docker Hub: @@ -652,20 +226,9 @@ make runtime-materialize make runtime-publish-official-environments-oci ``` -`make runtime-publish-environment-oci` auto-exports the OCI layout for the selected -environment if it is missing. -The publisher accepts either `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` or -`OCI_REGISTRY_USERNAME` and `OCI_REGISTRY_PASSWORD`. -Docker Hub uploads are chunked by default for large rootfs layers; if you need to tune a slow -link, use `PYRO_OCI_UPLOAD_TIMEOUT_SECONDS`, `PYRO_OCI_UPLOAD_CHUNK_SIZE_BYTES`, and -`PYRO_OCI_REQUEST_TIMEOUT_SECONDS`. - For a local PyPI publish: ```bash export TWINE_PASSWORD='pypi-...' make pypi-publish ``` - -`make pypi-publish` defaults `TWINE_USERNAME` to `__token__`. -Set `PYPI_REPOSITORY_URL=https://test.pypi.org/legacy/` to publish to TestPyPI instead. diff --git a/docs/first-run.md b/docs/first-run.md index 842bb59..4ac064f 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -1,10 +1,15 @@ # First Run Transcript -This is the intended evaluator path for a first successful run on a supported host. +This is the intended evaluator-to-chat-host path for a first successful run on +a supported host. + Copy the commands as-is. Paths and timing values will differ on your machine. The same sequence works with an installed `pyro` binary by dropping the -`uvx --from pyro-mcp` prefix. If you are running from a source checkout instead -of the published package, replace `pyro` with `uv run pyro`. +`uvx --from pyro-mcp` prefix. If you are running from a source checkout +instead of the published package, replace `pyro` with `uv run pyro`. + +`pyro-mcp` currently has no users. Expect breaking changes while the chat-host +path is still being shaped. ## 1. Verify the host @@ -30,9 +35,10 @@ debian:12-build [installed|not installed] Debian 12 environment with Git and com ## 3. Pull the default environment -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. See -[host-requirements.md](host-requirements.md) for the full host requirements. +The first pull downloads an OCI environment from public Docker Hub, requires +outbound HTTPS access to `registry-1.docker.io`, and needs local cache space +for the guest image. See [host-requirements.md](host-requirements.md) for the +full host requirements. ```bash $ uvx --from pyro-mcp pyro env pull debian:12 @@ -45,9 +51,6 @@ Installed: yes Cache dir: /home/you/.cache/pyro-mcp/environments Default packages: bash, coreutils, git Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0 -Install manifest: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/environment.json -Kernel image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/vmlinux -Rootfs image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/rootfs.ext4 OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0 ``` @@ -62,239 +65,90 @@ $ uvx --from pyro-mcp pyro run debian:12 -- git --version git version ... ``` -The guest command output and the `[run] ...` summary are written to different streams, so they -may appear in either order in terminals or capture tools. Use `--json` if you need a -deterministic structured result. +The guest command output and the `[run] ...` summary are written to different +streams, so they may appear in either order in terminals or capture tools. Use +`--json` if you need a deterministic structured result. -## 5. Continue into the stable workspace path +## 5. Start the MCP server -The commands below use the published-package form. The same stable workspace path works with an -installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `uv run pyro` from -a source checkout. +Bare `pyro mcp serve` now starts `workspace-core`, which is the intended chat +path: + +```bash +$ uvx --from pyro-mcp pyro mcp serve +``` + +## 6. Connect a chat host + +Claude Code: + +```bash +$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +$ claude mcp list +``` + +Codex: + +```bash +$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +$ codex mcp list +``` + +OpenCode uses the local config shape shown in: + +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) + +Other host-specific references: + +- [claude_code_mcp.md](../examples/claude_code_mcp.md) +- [codex_mcp.md](../examples/codex_mcp.md) +- [mcp_client_config.md](../examples/mcp_client_config.md) + +## 7. Continue into a real workflow + +Once the host is connected, move to one of the five recipe docs in +[use-cases/README.md](use-cases/README.md). + +The shortest chat-first story is: + +- [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) + +If you want terminal-level visibility into what the agent gets, use the manual +workspace flow below: ```bash $ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" $ uvx --from pyro-mcp pyro workspace list -$ uvx --from pyro-mcp pyro workspace update "$WORKSPACE_ID" --label owner=codex $ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes $ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only $ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch $ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt $ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint -$ uvx --from pyro-mcp pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' $ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt -$ uvx --from pyro-mcp pyro workspace stop "$WORKSPACE_ID" -$ uvx --from pyro-mcp pyro workspace disk list "$WORKSPACE_ID" -$ uvx --from pyro-mcp pyro workspace disk read "$WORKSPACE_ID" note.txt --content-only -$ uvx --from pyro-mcp pyro workspace disk export "$WORKSPACE_ID" --output ./workspace.ext4 -$ uvx --from pyro-mcp pyro workspace start "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID" ``` -## 6. Optional one-shot demo and expanded workspace flow +Move to `--profile workspace-full` only when the chat really needs shells, +services, snapshots, secrets, network policy, or disk tools. + +## 8. Trust the smoke pack + +The repo now treats the full smoke pack as the trustworthy guest-backed +verification path for the advertised workflows: + +```bash +$ make smoke-use-cases +``` + +That runner creates real guest-backed workspaces, exercises all five documented +stories, exports concrete results where relevant, and cleans up on both success +and failure. + +## 9. Optional one-shot demo ```bash $ uvx --from pyro-mcp pyro demo -$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 -$ uvx --from pyro-mcp pyro workspace list -$ uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex -$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes -$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive -$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only -$ uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py -$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch -$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress -$ uvx --from pyro-mcp pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt -$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID -$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint -$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint -$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID note.txt --output ./note.txt -$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done' -$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -$ uvx --from pyro-mcp pyro mcp serve -$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve -$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve -``` - -For most chat hosts, bare `pyro mcp serve` now starts `workspace-core`, the -recommended first MCP profile. -Move to `workspace-full` only when the host truly needs shells, services, -snapshots, secrets, network policy, or disk tools. - -Host-specific MCP starts: - -- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md) -- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md) -- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md) - -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. - -Once that stable workspace flow works, continue with the five recipe docs in -[use-cases/README.md](use-cases/README.md) or run the real guest-backed smoke packs directly with -`make smoke-use-cases`. Treat that smoke pack as the trustworthy guest-backed -verification path for the advertised workspace workflows. - -When you need repeated commands in one sandbox, switch to `pyro workspace ...`: - -```bash -$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo -Workspace ID: ... -Environment: debian:12 -State: started -Workspace: /workspace -Workspace seed: directory from ... -Network policy: off -Execution mode: guest_vsock -Resources: 1 vCPU / 1024 MiB -Command count: 0 - -$ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes --dest src -[workspace-sync] workspace_id=... mode=directory source=... destination=/workspace/src entry_count=... bytes_written=... execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive -Workspace file path: /workspace/src -- /workspace/src/note.txt [file] bytes=... - -$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/note.txt -hello from synced workspace -[workspace-file-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch - -$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID -- cat src/note.txt -hello from synced workspace -[workspace-exec] workspace_id=... sequence=1 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... - -$ uvx --from pyro-mcp pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -[workspace-exec] workspace_id=... sequence=2 cwd=/workspace execution_mode=guest_vsock exit_code=0 duration_ms=... - -$ uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID -[workspace-diff] workspace_id=... total=... added=... modified=... deleted=... type_changed=... text_patched=... non_text=... ---- a/src/note.txt -+++ b/src/note.txt -@@ ... - -$ uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint -[workspace-snapshot-create] snapshot_name=checkpoint kind=named entry_count=... bytes_written=... - -$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint -Workspace reset from snapshot: checkpoint (named) -[workspace-reset] destination=/workspace entry_count=... bytes_written=... -Workspace ID: ... -State: started -Command count: 0 -Reset count: 1 - -$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -[workspace-export] workspace_id=... workspace_path=/workspace/src/note.txt output_path=... artifact_type=file entry_count=... bytes_written=... execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -[workspace-shell-open] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' -[workspace-shell-write] workspace_id=... shell_id=... state=running cwd=/workspace cols=120 rows=30 execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 -/workspace -[workspace-shell-read] workspace_id=... shell_id=... state=running cursor=0 next_cursor=... truncated=False plain=True wait_for_idle_ms=300 execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' -[workspace-service-start] workspace_id=... service=web state=running cwd=/workspace ready_type=file execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' -[workspace-service-start] workspace_id=... service=worker state=running cwd=/workspace ready_type=file execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports -Workspace ID: ... -Network policy: egress+published-ports -... - -$ uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -[workspace-service-start] workspace_id=... service=app state=running cwd=/workspace ready_type=http execution_mode=guest_vsock published=127.0.0.1:18080->8080/tcp - -$ uvx --from pyro-mcp pyro workspace service list WORKSPACE_ID -Workspace: ... -Services: 2 total, 2 running -- web [running] cwd=/workspace readiness=file -- worker [running] cwd=/workspace readiness=file - -$ uvx --from pyro-mcp pyro workspace service status WORKSPACE_ID web -Workspace: ... -Service: web -State: running -Command: sh -lc 'touch .web-ready && while true; do sleep 60; done' -Cwd: /workspace -Readiness: file /workspace/.web-ready -Execution mode: guest_vsock - -$ uvx --from pyro-mcp pyro workspace service logs WORKSPACE_ID web --tail-lines 50 -Workspace: ... -Service: web -State: running -... - -$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID web -[workspace-service-stop] workspace_id=... service=web state=stopped execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace service stop WORKSPACE_ID worker -[workspace-service-stop] workspace_id=... service=worker state=stopped execution_mode=guest_vsock - -$ uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID -Workspace ID: ... -State: stopped - -$ uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID src --recursive -Workspace: ... -Path: /workspace/src -- /workspace/src [directory] -- /workspace/src/note.txt [file] bytes=... - -$ uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID src/note.txt -hello from synced workspace -[workspace-disk-read] workspace_id=... path=/workspace/src/note.txt size_bytes=... truncated=False - -$ uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 -[workspace-disk-export] workspace_id=... output_path=... disk_format=ext4 bytes_written=... - -$ uvx --from pyro-mcp pyro workspace start WORKSPACE_ID -Workspace ID: ... -State: started -``` - -Use `--seed-path` when the workspace should start from a host directory or a local -`.tar` / `.tar.gz` / `.tgz` archive instead of an empty `/workspace`. Use -`pyro workspace sync push` when you need to import later host-side changes into a started -workspace. Sync is non-atomic in `4.0.0`; if it fails partway through, prefer `pyro workspace reset` -to recover from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current -`/workspace` tree to its immutable create-time baseline, `pyro workspace snapshot *` to create -named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the -host. Use `pyro workspace file *` and `pyro workspace patch apply` for model-native text edits, -`pyro workspace exec` for one-shot commands, and `pyro workspace shell *` when you -need a persistent interactive PTY session in that same workspace. Use `pyro workspace service *` -when the workspace needs long-running background processes with typed readiness checks. Internal -service state and logs stay outside `/workspace`, so service runtime data does not appear in -workspace diff or export results. Use `--network-policy egress` for outbound guest networking, and -`--network-policy egress+published-ports` plus `workspace service start --publish` when one -service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at -workspace creation when the sandbox needs private tokens or config. Persisted secret files are -materialized at `/run/pyro-secrets/`, and `--secret-env SECRET_NAME[=ENV_VAR]` maps one -secret into one exec, shell, or service call without storing that environment mapping on the -workspace itself. Use `pyro workspace stop` plus `pyro workspace disk list|read|export` when you -need offline inspection or one raw ext4 copy from a stopped guest-backed workspace, then -`pyro workspace start` to resume the same workspace. - -The stable workspace walkthrough GIF in the README is rendered from -[docs/assets/workspace-first-run.tape](assets/workspace-first-run.tape) with -[scripts/render_tape.sh](../scripts/render_tape.sh). - -Example output: - -```json { "cleanup": { "deleted": true, @@ -309,7 +163,5 @@ Example output: } ``` -When you are done evaluating and want to remove stale cached environments, run `pyro env prune`. - -If `pyro doctor` reports `Runtime: FAIL`, or if the `pyro run` summary does not show -`execution_mode=guest_vsock`, stop and use [troubleshooting.md](troubleshooting.md). +`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end +to end. diff --git a/docs/install.md b/docs/install.md index 88f3d84..6ef5649 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,11 +1,17 @@ # Install +`pyro-mcp` is built for chat-based coding agents on Linux `x86_64` with KVM. +This document is intentionally biased toward that path. + +`pyro-mcp` currently has no users. Expect breaking changes while the chat-host +flow is still being shaped. + ## Support Matrix Supported today: -- Linux x86_64 -- Python 3.12+ +- Linux `x86_64` +- Python `3.12+` - `uv` - `/dev/kvm` @@ -53,11 +59,12 @@ pyro env pull debian:12 pyro run debian:12 -- git --version ``` -If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. +If you are running from a repo checkout instead, replace `pyro` with +`uv run pyro`. -After that one-shot proof works, continue into the stable workspace path with `pyro workspace ...`. +After that one-shot proof works, the intended next step is `pyro mcp serve`. -### 1. Check the host first +## 1. Check the host ```bash uvx --from pyro-mcp pyro doctor @@ -76,7 +83,7 @@ Networking: tun=yes ip_forward=yes If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md). -### 2. Inspect the catalog +## 2. Inspect the catalog ```bash uvx --from pyro-mcp pyro env list @@ -91,15 +98,16 @@ debian:12-base [installed|not installed] Minimal Debian 12 environment for shell debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. ``` -### 3. Pull the default environment +## 3. Pull the default environment ```bash uvx --from pyro-mcp pyro env pull debian:12 ``` -The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS -access to `registry-1.docker.io`, and needs local cache space for the guest image. See -[host-requirements.md](host-requirements.md) for the full host requirements. +The first pull downloads an OCI environment from public Docker Hub, requires +outbound HTTPS access to `registry-1.docker.io`, and needs local cache space +for the guest image. See [host-requirements.md](host-requirements.md) for the +full host requirements. Expected success signals: @@ -110,7 +118,7 @@ Pulled: debian:12 ... ``` -### 4. Run one command in a guest +## 4. Run one command in a guest ```bash uvx --from pyro-mcp pyro run debian:12 -- git --version @@ -126,119 +134,14 @@ Expected success signals: git version ... ``` -The guest command output and the `[run] ...` summary are written to different streams, so they -may appear in either order in terminals or capture tools. Use `--json` if you need a +The guest command output and the `[run] ...` summary are written to different +streams, so they may appear in either order. Use `--json` if you need a deterministic structured result. -If guest execution is unavailable, the command fails unless you explicitly pass -`--allow-host-compat`. +## 5. Connect a chat host -## 5. Continue into the stable workspace path - -The commands below use plain `pyro ...`. Run the same flow with `uvx --from pyro-mcp pyro ...` -for the published package, or `uv run pyro ...` from a source checkout. - -```bash -uv tool install pyro-mcp -WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" -pyro workspace list -pyro workspace update "$WORKSPACE_ID" --label owner=codex -pyro workspace sync push "$WORKSPACE_ID" ./changes -pyro workspace file read "$WORKSPACE_ID" note.txt --content-only -pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch -pyro workspace exec "$WORKSPACE_ID" -- cat note.txt -pyro workspace snapshot create "$WORKSPACE_ID" checkpoint -pyro workspace service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' -pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint -pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt -pyro workspace delete "$WORKSPACE_ID" -``` - -This is the stable persistent-workspace contract: - -- `workspace create` seeds `/workspace` -- `workspace create --name/--label`, `workspace list`, and `workspace update` make workspaces discoverable -- `workspace sync push` imports later host-side changes -- `workspace file *` and `workspace patch apply` cover model-native text inspection and edits -- `workspace exec` and `workspace shell *` keep work inside one sandbox -- `workspace service *` manages long-running processes with typed readiness -- `workspace snapshot *` and `workspace reset` make reset-over-repair explicit -- `workspace diff` compares against the immutable create-time baseline -- `workspace export` copies results back to the host -- `workspace stop|start` and `workspace disk *` add secondary stopped-workspace inspection and raw ext4 export - -When that stable workspace path is working, continue with the recipe index at -[use-cases/README.md](use-cases/README.md). It groups the five core workspace stories and the -real smoke targets behind them, starting with `make smoke-use-cases` or one of the per-scenario -targets such as `make smoke-repro-fix-loop`. -Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised -workspace workflows. - -## 6. Optional demo proof point - -```bash -uvx --from pyro-mcp pyro demo -``` - -`pyro demo` proves the one-shot create/start/exec/delete VM lifecycle works end to end. - -Example output: - -```json -{ - "cleanup": { - "deleted": true, - "reason": "post_exec_cleanup", - "vm_id": "..." - }, - "command": "git --version", - "environment": "debian:12", - "execution_mode": "guest_vsock", - "exit_code": 0, - "stdout": "git version ...\n" -} -``` - -For a fuller copy-pasteable transcript, see [first-run.md](first-run.md). -When you are done evaluating and want to remove stale cached environments, run `pyro env prune`. - -## Installed CLI - -If you already installed the package, the same evaluator path works with plain `pyro ...`: - -```bash -uv tool install pyro-mcp -pyro --version -pyro doctor -pyro env list -pyro env pull debian:12 -pyro run debian:12 -- git --version -``` - -After the CLI path works, you can move on to: - -- persistent workspaces: `pyro workspace create debian:12 --seed-path ./repo` -- workspace discovery metadata: `pyro workspace create debian:12 --name repro-fix --label issue=123` -- workspace discovery commands: `pyro workspace list` and `pyro workspace update WORKSPACE_ID --label owner=codex` -- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes` -- guest networking policy: `pyro workspace create debian:12 --network-policy egress` -- workspace secrets: `pyro workspace create debian:12 --secret API_TOKEN=expected --secret-file PIP_TOKEN=./token.txt` -- model-native file editing: `pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` -- baseline diff: `pyro workspace diff WORKSPACE_ID` -- snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint` -- host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` -- stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` -- interactive shells: `pyro workspace shell open WORKSPACE_ID --id-only` -- long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` -- localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` -- MCP: `pyro mcp serve` -- Python SDK: `from pyro_mcp import Pyro` -- Demos: `pyro demo` or `pyro demo --network` - -## Chat Host Quickstart - -For most chat-host integrations, bare `pyro mcp serve` now starts -`workspace-core`: +Bare `pyro mcp serve` now starts `workspace-core`, which is the default +product path. ```bash uvx --from pyro-mcp pyro mcp serve @@ -246,10 +149,10 @@ uvx --from pyro-mcp pyro mcp serve Copy-paste host-specific starts: -- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md) -- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md) -- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md) +- Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md) +- Codex setup: [codex_mcp.md](../examples/codex_mcp.md) +- OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json) +- Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md) Claude Code: @@ -263,86 +166,87 @@ Codex: codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` -OpenCode uses the `mcp`/`type: "local"` config shape shown in -[examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json). If -`pyro-mcp` is already installed, replace the `uvx --from pyro-mcp pyro` -command with `pyro` in the same host-specific command or config shape. Use -`--profile workspace-full` only when the host truly needs the full advanced -workspace surface. +OpenCode uses the `mcp` / `type: "local"` config shape shown in +[opencode_mcp_config.json](../examples/opencode_mcp_config.json). -Use profile progression like this: +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same command or config shape. -- `workspace-core`: default and recommended first profile for normal persistent chat editing -- `vm-run`: one-shot-only integrations -- `workspace-full`: explicit advanced opt-in when the host truly needs shells, services, snapshots, secrets, network policy, or disk tools +Use `--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. -## Stable Workspace +## 6. Go from zero to hero -Use `pyro workspace ...` when you need repeated commands in one sandbox instead of one-shot `pyro run`. +The intended user journey is: + +1. validate the host with `pyro doctor` +2. pull `debian:12` +3. prove guest execution with `pyro run debian:12 -- git --version` +4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` +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` + +If you want the shortest chat-first story, start with +[use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md). + +## 7. Manual terminal workspace flow + +If you want to inspect the workspace model directly from the terminal, use the +companion flow below. This is for understanding and debugging the chat-host +product, not the primary story. ```bash -pyro workspace create debian:12 --seed-path ./repo -pyro workspace create debian:12 --network-policy egress -pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected -pyro workspace create debian:12 --network-policy egress+published-ports -pyro workspace sync push WORKSPACE_ID ./changes --dest src -pyro workspace file list WORKSPACE_ID src --recursive -pyro workspace file read WORKSPACE_ID src/note.txt --content-only -pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py -pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch -pyro workspace exec WORKSPACE_ID -- cat src/note.txt -pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"' -pyro workspace diff WORKSPACE_ID -pyro workspace snapshot create WORKSPACE_ID checkpoint -pyro workspace reset WORKSPACE_ID --snapshot checkpoint -pyro workspace reset WORKSPACE_ID -pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt -pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN --id-only -pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' -pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 -pyro workspace shell close WORKSPACE_ID SHELL_ID -pyro workspace service start WORKSPACE_ID web --secret-env API_TOKEN --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done' -pyro workspace service start WORKSPACE_ID worker --ready-file .worker-ready -- sh -lc 'touch .worker-ready && while true; do sleep 60; done' -pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app -pyro workspace service list WORKSPACE_ID -pyro workspace service status WORKSPACE_ID web -pyro workspace service logs WORKSPACE_ID web --tail-lines 50 -pyro workspace service stop WORKSPACE_ID web -pyro workspace service stop WORKSPACE_ID worker -pyro workspace stop WORKSPACE_ID -pyro workspace disk list WORKSPACE_ID -pyro workspace disk read WORKSPACE_ID src/note.txt --content-only -pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 -pyro workspace start WORKSPACE_ID -pyro workspace logs WORKSPACE_ID -pyro workspace delete WORKSPACE_ID +uv tool install pyro-mcp +WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)" +pyro workspace list +pyro workspace update "$WORKSPACE_ID" --label owner=codex +pyro workspace sync push "$WORKSPACE_ID" ./changes +pyro workspace file read "$WORKSPACE_ID" note.txt --content-only +pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch +pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace snapshot create "$WORKSPACE_ID" checkpoint +pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint +pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt +pyro workspace delete "$WORKSPACE_ID" ``` -Workspace commands default to the persistent `/workspace` directory inside the guest. If you need -the identifier programmatically, use `--id-only` for only the identifier or `--json` for the full -workspace payload. Use `--seed-path` -when the workspace should start from a host directory or a local `.tar` / `.tar.gz` / `.tgz` -archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync -is non-atomic in `4.0.0`; if it fails partway through, prefer `pyro workspace reset` to recover -from `baseline` or one named snapshot. Use `pyro workspace diff` to compare the current workspace -tree to its immutable create-time baseline, `pyro workspace snapshot *` to capture named -checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use -`pyro workspace exec` for one-shot commands and `pyro workspace shell *` when you need an -interactive PTY that survives across separate calls. Prefer -`pyro workspace shell read --plain --wait-for-idle-ms 300` for chat-facing shell loops. Use `pyro workspace service *` when the -workspace needs long-running background processes with typed readiness probes. Service metadata and -logs stay outside `/workspace`, so the service runtime itself does not show up in workspace diff or -export results. Use `--network-policy egress` when the workspace needs outbound guest networking, -and `--network-policy egress+published-ports` plus `workspace service start --publish` when one -service must be reachable from the host on `127.0.0.1`. Use `--secret` and `--secret-file` at -workspace creation when the sandbox needs private tokens or config, and -`--secret-env SECRET_NAME[=ENV_VAR]` when one exec, shell, or service call needs that secret as an -environment variable. Persisted secret files are available in the guest at -`/run/pyro-secrets/`. Use `pyro workspace stop` plus `pyro workspace disk list|read|export` -when you need offline inspection or one raw ext4 copy from a stopped guest-backed workspace, then -`pyro workspace start` to resume it. +When you need deeper debugging or richer recipes, add: -## Contributor Clone +- `pyro workspace shell *` for interactive PTY state +- `pyro workspace service *` for long-running processes and readiness probes +- `pyro workspace create --network-policy egress+published-ports` plus + `workspace service start --publish` for host-probed services +- `pyro workspace create --secret` and `--secret-file` when the sandbox needs + private tokens +- `pyro workspace stop` plus `workspace disk *` for offline inspection + +## 8. Trustworthy verification path + +The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed +by a real Firecracker smoke pack: + +```bash +make smoke-use-cases +``` + +Treat that smoke pack as the trustworthy guest-backed verification path for the +advertised chat-host workflows. + +## Installed CLI + +If you already installed the package, the same path works with plain `pyro ...`: + +```bash +uv tool install pyro-mcp +pyro --version +pyro doctor +pyro env list +pyro env pull debian:12 +pyro run debian:12 -- git --version +pyro mcp serve +``` + +## Contributor clone ```bash git lfs install diff --git a/docs/integrations.md b/docs/integrations.md index 2883784..c3bbcb8 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -1,164 +1,141 @@ -# Integration Targets +# Chat Host Integrations -These are the main ways to integrate `pyro-mcp` into an LLM application. +This page documents the intended product path for `pyro-mcp`: -Use this page after you have already validated the host and guest execution through the -CLI path in [install.md](install.md) or [first-run.md](first-run.md). +- validate the host with the CLI +- run `pyro mcp serve` +- connect a chat host +- let the agent work inside disposable workspaces + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +Use this page after you have already validated the host and guest execution +through [install.md](install.md) or [first-run.md](first-run.md). ## Recommended Default -Bare `pyro mcp serve` now starts `workspace-core`. Use `vm_run` only for one-shot -integrations, and promote the chat surface to `workspace-full` only when it -truly needs shells, services, snapshots, secrets, network policy, or disk -tools. +Bare `pyro mcp serve` starts `workspace-core`. That is the product path. -That keeps the model-facing contract small: +```bash +pyro mcp serve +``` -- one tool -- one command -- one ephemeral VM -- automatic cleanup +Use `--profile workspace-full` only when the chat truly needs shells, services, +snapshots, secrets, network policy, or disk tools. -Profile progression: +## Claude Code -- `workspace-core`: default and recommended first profile for persistent chat editing -- `vm-run`: one-shot only -- `workspace-full`: the full stable workspace surface, including shells, services, snapshots, secrets, network policy, and disk tools +Package without install: -## OpenAI Responses API +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +claude mcp list +``` -Best when: +Already installed: -- your agent already uses OpenAI models directly -- you want a normal tool-calling loop instead of MCP transport -- you want the smallest amount of integration code +```bash +claude mcp add pyro -- pyro mcp serve +claude mcp list +``` -Recommended surface: +Reference: -- `vm_run` for one-shot loops -- the `workspace-core` tool set for the normal persistent chat loop -- the `workspace-full` tool set only when the host explicitly needs advanced workspace capabilities +- [claude_code_mcp.md](../examples/claude_code_mcp.md) -Canonical example: +## Codex -- [examples/openai_responses_vm_run.py](../examples/openai_responses_vm_run.py) -- [examples/openai_responses_workspace_core.py](../examples/openai_responses_workspace_core.py) -- [docs/use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) +Package without install: -## MCP Clients +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +codex mcp list +``` -Best when: +Already installed: -- your host application already supports MCP -- you want `pyro` to run as an external stdio server -- you want tool schemas to be discovered directly from the server +```bash +codex mcp add pyro -- pyro mcp serve +codex mcp list +``` -Recommended entrypoint: +Reference: -- `pyro mcp serve` +- [codex_mcp.md](../examples/codex_mcp.md) -Profile progression: +## OpenCode -- `pyro mcp serve --profile vm-run` for the smallest one-shot surface -- `pyro mcp serve` for the normal persistent chat loop -- `pyro mcp serve --profile workspace-full` only when the model truly needs advanced workspace tools +Use the local MCP config shape from: -Host-specific onramps: +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Claude Code: [examples/claude_code_mcp.md](../examples/claude_code_mcp.md) -- Codex: [examples/codex_mcp.md](../examples/codex_mcp.md) -- OpenCode: [examples/opencode_mcp_config.json](../examples/opencode_mcp_config.json) -- Generic MCP config: [examples/mcp_client_config.md](../examples/mcp_client_config.md) -- Claude Desktop fallback: [examples/claude_desktop_mcp_config.json](../examples/claude_desktop_mcp_config.json) -- Cursor fallback: [examples/cursor_mcp_config.json](../examples/cursor_mcp_config.json) -- Use-case recipes: [docs/use-cases/README.md](use-cases/README.md) +Minimal `opencode.json` snippet: -## Direct Python SDK +```json +{ + "mcp": { + "pyro": { + "type": "local", + "enabled": true, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + } + } +} +``` -Best when: +If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with +`pyro` in the same config shape. -- your application owns orchestration itself -- you do not need MCP transport -- you want direct access to `Pyro` +## Generic MCP Fallback -Recommended default: +Use this only when the host expects a plain `mcpServers` JSON config and does +not already have a dedicated example in the repo: -- `Pyro.run_in_vm(...)` -- `Pyro.create_server()` for most chat hosts now that `workspace-core` is the default profile -- `Pyro.create_workspace(name=..., labels=...)` + `Pyro.list_workspaces()` + `Pyro.update_workspace(...)` when repeated workspaces need human-friendly discovery metadata -- `Pyro.create_workspace(seed_path=...)` + `Pyro.push_workspace_sync(...)` + `Pyro.exec_workspace(...)` when repeated workspace commands are required -- `Pyro.list_workspace_files(...)` / `Pyro.read_workspace_file(...)` / `Pyro.write_workspace_file(...)` / `Pyro.apply_workspace_patch(...)` when the agent needs model-native file inspection and text edits inside one live workspace -- `Pyro.create_workspace(..., secrets=...)` + `Pyro.exec_workspace(..., secret_env=...)` when the workspace needs private tokens or authenticated setup -- `Pyro.create_workspace(..., network_policy="egress+published-ports")` + `Pyro.start_service(..., published_ports=[...])` when the host must probe one workspace service -- `Pyro.diff_workspace(...)` + `Pyro.export_workspace(...)` when the agent needs baseline comparison or host-out file transfer -- `Pyro.start_service(..., secret_env=...)` + `Pyro.list_services(...)` + `Pyro.logs_service(...)` when the agent needs long-running background processes in one workspace -- `Pyro.open_shell(..., secret_env=...)` + `Pyro.write_shell(...)` + `Pyro.read_shell(..., plain=True, wait_for_idle_ms=300)` when the agent needs an interactive PTY inside the workspace +- [mcp_client_config.md](../examples/mcp_client_config.md) -Lifecycle note: +Generic `mcpServers` shape: -- `Pyro.exec_vm(...)` runs one command and auto-cleans the VM afterward -- use `create_vm(...)` + `start_vm(...)` only when you need pre-exec inspection or status before - that final exec -- use `create_workspace(seed_path=...)` when the agent needs repeated commands in one persistent - `/workspace` that starts from host content -- use `create_workspace(name=..., labels=...)`, `list_workspaces()`, and `update_workspace(...)` - when the agent or operator needs to rediscover the right workspace later without external notes -- use `push_workspace_sync(...)` when later host-side changes need to be imported into that - running workspace without recreating it -- use `list_workspace_files(...)`, `read_workspace_file(...)`, `write_workspace_file(...)`, and - `apply_workspace_patch(...)` when the agent should inspect or edit workspace files without shell - quoting tricks -- use `create_workspace(..., secrets=...)` plus `secret_env` on exec, shell, or service start when - the agent needs private tokens or authenticated startup inside that workspace -- use `create_workspace(..., network_policy="egress+published-ports")` plus - `start_service(..., published_ports=[...])` when the host must probe one service from that - workspace -- use `diff_workspace(...)` when the agent needs a structured comparison against the immutable - create-time baseline -- use `export_workspace(...)` when the agent needs one file or directory copied back to the host -- use `stop_workspace(...)` plus `list_workspace_disk(...)`, `read_workspace_disk(...)`, or - `export_workspace_disk(...)` when the agent needs offline inspection or one raw ext4 copy from - a stopped guest-backed workspace -- use `start_service(...)` when the agent needs long-running processes and typed readiness inside - one workspace -- use `open_shell(...)` when the agent needs interactive shell state instead of one-shot execs +```json +{ + "mcpServers": { + "pyro": { + "command": "uvx", + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + } + } +} +``` -Examples: +## When To Use `workspace-full` -- [examples/python_run.py](../examples/python_run.py) -- [examples/python_lifecycle.py](../examples/python_lifecycle.py) -- [examples/python_workspace.py](../examples/python_workspace.py) -- [examples/python_shell.py](../examples/python_shell.py) -- [docs/use-cases/README.md](use-cases/README.md) +Stay on bare `pyro mcp serve` unless the chat host truly needs: -## Agent Framework Wrappers +- persistent PTY shell sessions +- long-running services and readiness probes +- secrets +- guest networking and published ports +- stopped-workspace disk inspection or raw ext4 export -Examples: +When that is necessary: -- LangChain tools -- PydanticAI tools -- custom in-house orchestration layers +```bash +pyro mcp serve --profile workspace-full +``` -Best when: +## Recipe-Backed Workflows -- you already have an application framework that expects a Python callable tool -- you want to wrap `vm_run` behind framework-specific abstractions +Once the host is connected, move to the five real workflows in +[use-cases/README.md](use-cases/README.md): -Recommended pattern: +- cold-start repo validation +- repro plus fix loops +- parallel isolated workspaces +- unsafe or untrusted code inspection +- review and evaluation workflows -- keep the framework wrapper thin -- map one-shot framework tool input directly onto `vm_run` -- expose `workspace_*` only when the framework truly needs repeated commands in one workspace +Validate the whole story with: -Concrete example: - -- [examples/langchain_vm_run.py](../examples/langchain_vm_run.py) - -## Selection Rule - -Choose the narrowest integration that matches the host environment: - -1. OpenAI Responses API if you want a direct provider tool loop. -2. MCP if your host already speaks MCP. -3. Python SDK if you own orchestration and do not need transport. -4. Framework wrappers only as thin adapters over the same `vm_run` contract. +```bash +make smoke-use-cases +``` diff --git a/docs/public-contract.md b/docs/public-contract.md index d225c0b..0a4de23 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -1,375 +1,150 @@ # Public Contract -This document defines the stable public interface for `pyro-mcp` `3.x`. +This document describes the chat way to use `pyro-mcp` in `4.x`. + +`pyro-mcp` currently has no users. Expect breaking changes while this chat-host +path is still being shaped. + +This document is intentionally biased. It describes the path users are meant to +follow today: + +- prove the host with the terminal companion commands +- serve disposable workspaces over MCP +- connect Claude Code, Codex, or OpenCode +- use the recipe-backed workflows + +This page does not try to document every building block in the repo. It +documents the chat-host path the project is actively shaping. ## Package Identity -- Distribution name: `pyro-mcp` -- Public executable: `pyro` -- Public Python import: `from pyro_mcp import Pyro` -- Public package-level factory: `from pyro_mcp import create_server` +- distribution name: `pyro-mcp` +- public executable: `pyro` +- primary product entrypoint: `pyro mcp serve` -Stable product framing: +`pyro-mcp` is a disposable MCP workspace for chat-based coding agents on Linux +`x86_64` KVM hosts. -- `pyro run` is the stable one-shot entrypoint. -- `pyro workspace ...` is the stable persistent workspace contract. +## Supported Product Path -## CLI Contract +The intended user journey is: -Top-level commands: +1. `pyro doctor` +2. `pyro env list` +3. `pyro env pull debian:12` +4. `pyro run debian:12 -- git --version` +5. `pyro mcp serve` +6. connect Claude Code, Codex, or OpenCode +7. run one of the documented recipe-backed workflows +8. validate the whole story with `make smoke-use-cases` +## Evaluator CLI + +These terminal commands are the documented companion path for the chat-host +product: + +- `pyro doctor` - `pyro env list` - `pyro env pull` -- `pyro env inspect` -- `pyro env prune` -- `pyro mcp serve` - `pyro run` -- `pyro workspace create` -- `pyro workspace list` -- `pyro workspace sync push` -- `pyro workspace stop` -- `pyro workspace start` -- `pyro workspace exec` -- `pyro workspace file list` -- `pyro workspace file read` -- `pyro workspace file write` -- `pyro workspace export` -- `pyro workspace patch apply` -- `pyro workspace disk export` -- `pyro workspace disk list` -- `pyro workspace disk read` -- `pyro workspace diff` -- `pyro workspace snapshot create` -- `pyro workspace snapshot list` -- `pyro workspace snapshot delete` -- `pyro workspace reset` -- `pyro workspace service start` -- `pyro workspace service list` -- `pyro workspace service status` -- `pyro workspace service logs` -- `pyro workspace service stop` -- `pyro workspace shell open` -- `pyro workspace shell read` -- `pyro workspace shell write` -- `pyro workspace shell signal` -- `pyro workspace shell close` -- `pyro workspace status` -- `pyro workspace update` -- `pyro workspace logs` -- `pyro workspace delete` -- `pyro doctor` - `pyro demo` -- `pyro demo ollama` -Stable `pyro run` interface: +What to expect from that path: -- positional environment name -- `--vcpu-count` -- `--mem-mib` -- `--timeout-seconds` -- `--ttl-seconds` -- `--network` -- `--allow-host-compat` -- `--json` +- `pyro run -- ` defaults to `1 vCPU / 1024 MiB` +- `pyro run` fails if guest boot or guest exec is unavailable unless + `--allow-host-compat` is set +- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, + `pyro env prune`, and `pyro doctor` are human-readable by default and return + structured JSON with `--json` +- the first official environment pull downloads from public Docker Hub into the + local environment cache +- `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to + end -Behavioral guarantees: +These commands exist to validate and debug the chat-host path. They are not the +main product destination. -- `pyro run -- ` defaults to `1 vCPU / 1024 MiB`. -- `pyro run` fails if guest boot or guest exec is unavailable unless `--allow-host-compat` is set. -- `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, `pyro env prune`, and `pyro doctor` are human-readable by default and return structured JSON with `--json`. -- `pyro demo ollama` prints log lines plus a final summary line. -- `pyro workspace create` auto-starts a persistent workspace. -- `pyro workspace create --seed-path PATH` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. -- `pyro workspace create --id-only` prints only the new `workspace_id` plus a trailing newline. -- `pyro workspace create --name NAME --label KEY=VALUE` attaches human-oriented discovery metadata without changing the stable `workspace_id`. -- `pyro workspace create --network-policy {off,egress,egress+published-ports}` controls workspace guest networking and whether services may publish localhost ports. -- `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` narrows the model-facing MCP surface without changing runtime behavior; `workspace-core` is the recommended first profile for most chat hosts. -- `pyro workspace create --secret NAME=VALUE` and `--secret-file NAME=PATH` persist guest-only UTF-8 secrets outside `/workspace`. -- `pyro workspace list` returns persisted workspaces sorted by most recent `last_activity_at`. -- `pyro workspace sync push WORKSPACE_ID SOURCE_PATH [--dest WORKSPACE_PATH]` imports later host-side directory or archive content into a started workspace. -- `pyro workspace stop WORKSPACE_ID` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. -- `pyro workspace start WORKSPACE_ID` restarts one stopped workspace without resetting `/workspace`. -- `pyro workspace file list WORKSPACE_ID [PATH] [--recursive]` returns metadata for one live path under `/workspace`. -- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular text file under `/workspace`. -- `pyro workspace file write WORKSPACE_ID PATH --text TEXT` and `--text-file PATH` create or replace one regular text file under `/workspace`, creating missing parent directories automatically. -- `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host. -- `pyro workspace disk export WORKSPACE_ID --output HOST_PATH` copies the stopped guest-backed workspace rootfs as raw ext4 to the host. -- `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular file from a stopped guest-backed workspace rootfs offline. -- `pyro workspace disk *` requires `state=stopped` and a guest-backed workspace; it fails on `host_compat`. -- `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline. -- `pyro workspace snapshot *` manages explicit named snapshots in addition to the implicit `baseline`. -- `pyro workspace reset WORKSPACE_ID [--snapshot SNAPSHOT_NAME|baseline]` recreates the full sandbox and restores `/workspace` from the chosen snapshot. -- `pyro workspace service *` manages long-running named services inside one started workspace with typed readiness probes. -- `pyro workspace service start --publish GUEST_PORT` or `--publish HOST_PORT:GUEST_PORT` publishes one guest TCP port to `127.0.0.1` on the host. -- `pyro workspace exec --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one exec call. -- `pyro workspace service start --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one service start call. -- `pyro workspace exec` runs in the persistent `/workspace` for that workspace and does not auto-clean. -- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` and `--patch-file PATH` apply one unified text patch with add/modify/delete operations under `/workspace`. -- `pyro workspace shell open --id-only` prints only the new `shell_id` plus a trailing newline. -- `pyro workspace shell open --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into the opened shell environment. -- `pyro workspace shell *` manages persistent PTY sessions inside a started workspace. -- `pyro workspace shell read --plain --wait-for-idle-ms 300` is the recommended chat-facing read mode; raw shell reads remain available without `--plain`. -- `pyro workspace logs` returns persisted command history for that workspace until `pyro workspace delete`. -- `pyro workspace update` changes only discovery metadata such as `name` and key/value `labels`. -- Workspace create/status results expose `workspace_seed` metadata describing how `/workspace` was initialized. -- Workspace create/status/reset/update results expose `name`, `labels`, and `last_activity_at`. -- Workspace create/status/reset results expose `network_policy`. -- Workspace create/status/reset results expose `reset_count` and `last_reset_at`. -- Workspace create/status/reset results expose safe `secrets` metadata with each secret name and source kind, but never the secret values. -- `pyro workspace status` includes aggregate `service_count` and `running_service_count` fields. -- `pyro workspace list` returns one summary row per persisted workspace with `workspace_id`, `name`, `labels`, `environment`, `state`, `created_at`, `last_activity_at`, `expires_at`, `command_count`, `service_count`, and `running_service_count`. -- `pyro workspace service start`, `pyro workspace service list`, and `pyro workspace service status` expose published-port metadata when present. +## MCP Entry Point -## Python SDK Contract +The product entrypoint is: -Primary facade: +```bash +pyro mcp serve +``` -- `Pyro` +What to expect: -Supported public entrypoints: +- bare `pyro mcp serve` starts `workspace-core` +- `workspace-core` is the default product path for chat hosts +- `pyro mcp serve --profile workspace-full` explicitly opts into the larger + tool surface +- `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface -- `create_server()` -- `Pyro.create_server()` -- `Pyro.list_environments()` -- `Pyro.pull_environment(environment)` -- `Pyro.inspect_environment(environment)` -- `Pyro.prune_environments()` -- `Pyro.create_vm(...)` -- `Pyro.create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)` -- `Pyro.list_workspaces()` -- `Pyro.push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` -- `Pyro.stop_workspace(workspace_id)` -- `Pyro.start_workspace(workspace_id)` -- `Pyro.list_workspace_files(workspace_id, path="/workspace", recursive=False)` -- `Pyro.read_workspace_file(workspace_id, path, *, max_bytes=65536)` -- `Pyro.write_workspace_file(workspace_id, path, *, text)` -- `Pyro.export_workspace(workspace_id, path, *, output_path)` -- `Pyro.apply_workspace_patch(workspace_id, *, patch)` -- `Pyro.export_workspace_disk(workspace_id, *, output_path)` -- `Pyro.list_workspace_disk(workspace_id, path="/workspace", recursive=False)` -- `Pyro.read_workspace_disk(workspace_id, path, *, max_bytes=65536)` -- `Pyro.diff_workspace(workspace_id)` -- `Pyro.create_snapshot(workspace_id, snapshot_name)` -- `Pyro.list_snapshots(workspace_id)` -- `Pyro.delete_snapshot(workspace_id, snapshot_name)` -- `Pyro.reset_workspace(workspace_id, *, snapshot="baseline")` -- `Pyro.start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)` -- `Pyro.list_services(workspace_id)` -- `Pyro.status_service(workspace_id, service_name)` -- `Pyro.logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` -- `Pyro.stop_service(workspace_id, service_name)` -- `Pyro.open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)` -- `Pyro.read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)` -- `Pyro.write_shell(workspace_id, shell_id, *, input, append_newline=True)` -- `Pyro.signal_shell(workspace_id, shell_id, *, signal_name="INT")` -- `Pyro.close_shell(workspace_id, shell_id)` -- `Pyro.start_vm(vm_id)` -- `Pyro.exec_vm(vm_id, *, command, timeout_seconds=30)` -- `Pyro.exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)` -- `Pyro.stop_vm(vm_id)` -- `Pyro.delete_vm(vm_id)` -- `Pyro.delete_workspace(workspace_id)` -- `Pyro.status_vm(vm_id)` -- `Pyro.status_workspace(workspace_id)` -- `Pyro.update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)` -- `Pyro.logs_workspace(workspace_id)` -- `Pyro.network_info_vm(vm_id)` -- `Pyro.reap_expired()` -- `Pyro.run_in_vm(...)` +Host-specific setup docs: -Stable public method names: +- [claude_code_mcp.md](../examples/claude_code_mcp.md) +- [codex_mcp.md](../examples/codex_mcp.md) +- [opencode_mcp_config.json](../examples/opencode_mcp_config.json) +- [mcp_client_config.md](../examples/mcp_client_config.md) -- `create_server()` -- `list_environments()` -- `pull_environment(environment)` -- `inspect_environment(environment)` -- `prune_environments()` -- `create_vm(...)` -- `create_workspace(..., name=None, labels=None, network_policy="off", secrets=None)` -- `list_workspaces()` -- `push_workspace_sync(workspace_id, source_path, *, dest="/workspace")` -- `stop_workspace(workspace_id)` -- `start_workspace(workspace_id)` -- `list_workspace_files(workspace_id, path="/workspace", recursive=False)` -- `read_workspace_file(workspace_id, path, *, max_bytes=65536)` -- `write_workspace_file(workspace_id, path, *, text)` -- `export_workspace(workspace_id, path, *, output_path)` -- `apply_workspace_patch(workspace_id, *, patch)` -- `export_workspace_disk(workspace_id, *, output_path)` -- `list_workspace_disk(workspace_id, path="/workspace", recursive=False)` -- `read_workspace_disk(workspace_id, path, *, max_bytes=65536)` -- `diff_workspace(workspace_id)` -- `create_snapshot(workspace_id, snapshot_name)` -- `list_snapshots(workspace_id)` -- `delete_snapshot(workspace_id, snapshot_name)` -- `reset_workspace(workspace_id, *, snapshot="baseline")` -- `start_service(workspace_id, service_name, *, command, cwd="/workspace", readiness=None, ready_timeout_seconds=30, ready_interval_ms=500, secret_env=None, published_ports=None)` -- `list_services(workspace_id)` -- `status_service(workspace_id, service_name)` -- `logs_service(workspace_id, service_name, *, tail_lines=200, all=False)` -- `stop_service(workspace_id, service_name)` -- `open_shell(workspace_id, *, cwd="/workspace", cols=120, rows=30, secret_env=None)` -- `read_shell(workspace_id, shell_id, *, cursor=0, max_chars=65536, plain=False, wait_for_idle_ms=None)` -- `write_shell(workspace_id, shell_id, *, input, append_newline=True)` -- `signal_shell(workspace_id, shell_id, *, signal_name="INT")` -- `close_shell(workspace_id, shell_id)` -- `start_vm(vm_id)` -- `exec_vm(vm_id, *, command, timeout_seconds=30)` -- `exec_workspace(workspace_id, *, command, timeout_seconds=30, secret_env=None)` -- `stop_vm(vm_id)` -- `delete_vm(vm_id)` -- `delete_workspace(workspace_id)` -- `status_vm(vm_id)` -- `status_workspace(workspace_id)` -- `update_workspace(workspace_id, *, name=None, clear_name=False, labels=None, clear_labels=None)` -- `logs_workspace(workspace_id)` -- `network_info_vm(vm_id)` -- `reap_expired()` -- `run_in_vm(...)` +## Chat-Facing Workspace Contract -Behavioral defaults: - -- `Pyro.create_vm(...)` and `Pyro.run_in_vm(...)` default to `vcpu_count=1` and `mem_mib=1024`. -- `Pyro.create_workspace(...)` defaults to `vcpu_count=1` and `mem_mib=1024`. -- `allow_host_compat` defaults to `False` on `create_vm(...)` and `run_in_vm(...)`. -- `allow_host_compat` defaults to `False` on `create_workspace(...)`. -- `Pyro.create_workspace(..., seed_path=...)` seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. -- `Pyro.create_workspace(..., name=..., labels=...)` attaches human-oriented discovery metadata without changing the stable `workspace_id`. -- `Pyro.create_workspace(..., network_policy="off"|"egress"|"egress+published-ports")` controls workspace guest networking and whether services may publish host ports. -- `Pyro.create_workspace(..., secrets=...)` persists guest-only UTF-8 secrets outside `/workspace`. -- `Pyro.list_workspaces()` returns persisted workspace summaries sorted by most recent `last_activity_at`. -- `Pyro.push_workspace_sync(...)` imports later host-side directory or archive content into a started workspace. -- `Pyro.stop_workspace(...)` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. -- `Pyro.start_workspace(...)` restarts one stopped workspace without resetting `/workspace`. -- `Pyro.list_workspace_files(...)`, `Pyro.read_workspace_file(...)`, and `Pyro.write_workspace_file(...)` provide structured live `/workspace` inspection and text edits without shell quoting. -- `Pyro.export_workspace(...)` exports one file or directory from `/workspace` to an explicit host path. -- `Pyro.apply_workspace_patch(...)` applies unified text patches for add/modify/delete operations under `/workspace`. -- `Pyro.export_workspace_disk(...)` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path. -- `Pyro.list_workspace_disk(...)` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `Pyro.read_workspace_disk(...)` reads one regular file from a stopped guest-backed workspace rootfs offline. -- stopped-workspace disk helpers require `state=stopped` and a guest-backed workspace; they fail on `host_compat`. -- `Pyro.diff_workspace(...)` compares the current `/workspace` tree to the immutable create-time baseline. -- `Pyro.create_snapshot(...)` captures one named `/workspace` checkpoint. -- `Pyro.list_snapshots(...)` lists the implicit `baseline` plus any named snapshots. -- `Pyro.delete_snapshot(...)` deletes one named snapshot while leaving `baseline` intact. -- `Pyro.reset_workspace(...)` recreates the full sandbox from `baseline` or one named snapshot and clears command, shell, and service history. -- `Pyro.start_service(..., secret_env=...)` maps persisted workspace secrets into that service process as environment variables for that start call only. -- `Pyro.start_service(...)` starts one named long-running process in a started workspace and waits for its typed readiness probe when configured. -- `Pyro.start_service(..., published_ports=[...])` publishes one or more guest TCP ports to `127.0.0.1` on the host when the workspace network policy is `egress+published-ports`. -- `Pyro.list_services(...)`, `Pyro.status_service(...)`, `Pyro.logs_service(...)`, and `Pyro.stop_service(...)` manage those persisted workspace services. -- `Pyro.exec_vm(...)` runs one command and auto-cleans that VM after the exec completes. -- `Pyro.exec_workspace(..., secret_env=...)` maps persisted workspace secrets into that exec call as environment variables for that call only. -- `Pyro.exec_workspace(...)` runs one command in the persistent workspace and leaves it alive. -- `Pyro.open_shell(..., secret_env=...)` maps persisted workspace secrets into the shell environment when that shell opens. -- `Pyro.open_shell(...)` opens a persistent PTY shell attached to one started workspace. -- `Pyro.read_shell(...)` reads merged text output from that shell by cursor, with optional plain rendering and idle batching for chat-facing consumers. -- `Pyro.write_shell(...)`, `Pyro.signal_shell(...)`, and `Pyro.close_shell(...)` operate on that persistent shell session. -- `Pyro.update_workspace(...)` changes only discovery metadata such as `name` and key/value `labels`. - -## MCP Contract - -Stable MCP profiles: - -- `vm-run`: exposes only `vm_run` -- `workspace-core`: exposes `vm_run`, `workspace_create`, `workspace_list`, `workspace_update`, `workspace_status`, `workspace_sync_push`, `workspace_exec`, `workspace_logs`, `workspace_file_list`, `workspace_file_read`, `workspace_file_write`, `workspace_patch_apply`, `workspace_diff`, `workspace_export`, `workspace_reset`, and `workspace_delete` -- `workspace-full`: exposes the complete stable MCP surface below - -Behavioral defaults: - -- `pyro mcp serve`, `create_server()`, and `Pyro.create_server()` default to `workspace-core`. -- `workspace-core` is the default and recommended first profile for most new chat-host integrations. -- `create_server(profile="workspace-full")` and `Pyro.create_server(profile="workspace-full")` opt into the full advanced workspace surface explicitly. -- `workspace-core` narrows `workspace_create` by omitting `network_policy` and `secrets`. -- `workspace-core` narrows `workspace_exec` by omitting `secret_env`. - -Primary tool: +`workspace-core` is the normal chat path. It exposes: - `vm_run` - -Advanced lifecycle tools: - -- `vm_list_environments` -- `vm_create` -- `vm_start` -- `vm_exec` -- `vm_stop` -- `vm_delete` -- `vm_status` -- `vm_network_info` -- `vm_reap_expired` - -Persistent workspace tools: - - `workspace_create` - `workspace_list` +- `workspace_update` +- `workspace_status` - `workspace_sync_push` -- `workspace_stop` -- `workspace_start` - `workspace_exec` +- `workspace_logs` - `workspace_file_list` - `workspace_file_read` - `workspace_file_write` -- `workspace_export` - `workspace_patch_apply` -- `workspace_disk_export` -- `workspace_disk_list` -- `workspace_disk_read` - `workspace_diff` -- `snapshot_create` -- `snapshot_list` -- `snapshot_delete` +- `workspace_export` - `workspace_reset` -- `service_start` -- `service_list` -- `service_status` -- `service_logs` -- `service_stop` -- `shell_open` -- `shell_read` -- `shell_write` -- `shell_signal` -- `shell_close` -- `workspace_status` -- `workspace_update` -- `workspace_logs` - `workspace_delete` -Behavioral defaults: +That is enough for the normal persistent editing loop: -- `vm_run` and `vm_create` default to `vcpu_count=1` and `mem_mib=1024`. -- `workspace_create` defaults to `vcpu_count=1` and `mem_mib=1024`. -- `vm_run` and `vm_create` expose `allow_host_compat`, which defaults to `false`. -- `workspace_create` exposes `allow_host_compat`, which defaults to `false`. -- `workspace_create` accepts optional `seed_path` and seeds `/workspace` from a host directory or a local `.tar` / `.tar.gz` / `.tgz` archive before the workspace is returned. -- `workspace_create` accepts optional `name` and `labels` metadata for human discovery without changing the stable `workspace_id`. -- `workspace_create` accepts `network_policy` with `off`, `egress`, or `egress+published-ports` to control workspace guest networking and service port publication. -- `workspace_create` accepts optional `secrets` and persists guest-only UTF-8 secret material outside `/workspace`. -- `workspace_list` returns persisted workspace summaries sorted by most recent `last_activity_at`. -- `workspace_sync_push` imports later host-side directory or archive content into a started workspace, with an optional `dest` under `/workspace`. -- `workspace_stop` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. -- `workspace_start` restarts one stopped workspace without resetting `/workspace`. -- `workspace_file_list`, `workspace_file_read`, and `workspace_file_write` provide structured live `/workspace` inspection and text edits without shell quoting. -- `workspace_export` exports one file or directory from `/workspace` to an explicit host path. -- `workspace_patch_apply` applies unified text patches for add/modify/delete operations under `/workspace`. -- `workspace_disk_export` copies the stopped guest-backed workspace rootfs as raw ext4 to an explicit host path. -- `workspace_disk_list` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `workspace_disk_read` reads one regular file from a stopped guest-backed workspace rootfs offline. -- stopped-workspace disk tools require `state=stopped` and a guest-backed workspace; they fail on `host_compat`. -- `workspace_diff` compares the current `/workspace` tree to the immutable create-time baseline. -- `snapshot_create`, `snapshot_list`, and `snapshot_delete` manage explicit named snapshots in addition to the implicit `baseline`. -- `workspace_reset` recreates the full sandbox and restores `/workspace` from `baseline` or one named snapshot. -- `service_start`, `service_list`, `service_status`, `service_logs`, and `service_stop` manage persistent named services inside a started workspace. -- `service_start` accepts optional `published_ports` to expose guest TCP ports on `127.0.0.1` when the workspace network policy is `egress+published-ports`. -- `vm_exec` runs one command and auto-cleans that VM after the exec completes. -- `workspace_exec` accepts optional `secret_env` mappings for one exec call and leaves the workspace alive. -- `workspace_update` changes only discovery metadata such as `name` and key/value `labels`. -- `service_start` accepts optional `secret_env` mappings for one service start call. -- `shell_open` accepts optional `secret_env` mappings for the opened shell session. -- `shell_open`, `shell_read`, `shell_write`, `shell_signal`, and `shell_close` manage persistent PTY shells inside a started workspace. +- create one workspace +- sync or seed repo content +- inspect and edit files without shell quoting +- run commands repeatedly in one sandbox +- diff and export results +- reset and retry +- delete the workspace when the task is done -## Versioning Rule +Move to `workspace-full` only when the chat truly needs: -- `pyro-mcp` uses SemVer. -- Environment names are stable identifiers in the shipped catalog. -- Changing a public command name, public flag, public method name, public MCP tool name, or required request field is a breaking change. +- persistent PTY shell sessions +- long-running services and readiness probes +- secrets +- guest networking and published ports +- stopped-workspace disk inspection + +## Recipe-Backed Workflows + +The documented product workflows are: + +| Workflow | Recommended profile | Doc | +| --- | --- | --- | +| Cold-start repo validation | `workspace-full` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) | +| Repro plus fix loop | `workspace-core` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) | +| Parallel isolated workspaces | `workspace-core` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) | +| Unsafe or untrusted code inspection | `workspace-core` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) | +| Review and evaluation workflows | `workspace-full` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) | + +Treat this smoke pack as the trustworthy guest-backed verification path for the +advertised product: + +```bash +make smoke-use-cases +``` + +The chat-host MCP path above is the thing the docs are intentionally shaping +around. diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index 15911d6..0f93188 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -1,6 +1,6 @@ # Workspace Use-Case Recipes -These recipes turn the stable workspace surface into five concrete agent flows. +These recipes turn the chat-host workspace path into five concrete agent flows. They are the canonical next step after the quickstart in [install.md](../install.md) or [first-run.md](../first-run.md). diff --git a/docs/use-cases/cold-start-repo-validation.md b/docs/use-cases/cold-start-repo-validation.md index f856906..3b303be 100644 --- a/docs/use-cases/cold-start-repo-validation.md +++ b/docs/use-cases/cold-start-repo-validation.md @@ -12,26 +12,14 @@ Use this flow when an agent needs to treat a fresh repo like a new user would: seed it into a workspace, run the validation script, keep one long-running process alive, probe it from another command, and export a validation report. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./repo") -workspace_id = str(created["workspace_id"]) - -pyro.exec_workspace(workspace_id, command="sh validate.sh") -pyro.start_service( - workspace_id, - "app", - command="sh serve.sh", - readiness={"type": "file", "path": ".app-ready"}, -) -pyro.exec_workspace(workspace_id, command="sh -lc 'test -f .app-ready && cat service-state.txt'") -pyro.export_workspace(workspace_id, "validation-report.txt", output_path="./validation-report.txt") -pyro.delete_workspace(workspace_id) -``` +1. Create one workspace from the repo seed. +2. Run the validation command inside that workspace. +3. Start the app as a long-running service with readiness configured. +4. Probe the ready service from another command in the same workspace. +5. Export the validation report back to the host. +6. Delete the workspace when the evaluation is done. This recipe is intentionally guest-local and deterministic. It proves startup, service readiness, validation, and host-out report capture without depending on diff --git a/docs/use-cases/parallel-workspaces.md b/docs/use-cases/parallel-workspaces.md index ddc29b7..ccabd7a 100644 --- a/docs/use-cases/parallel-workspaces.md +++ b/docs/use-cases/parallel-workspaces.md @@ -11,32 +11,14 @@ make smoke-parallel-workspaces Use this flow when the agent needs one isolated workspace per issue, branch, or review thread and must rediscover the right one later. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -alpha = pyro.create_workspace( - environment="debian:12", - seed_path="./repo", - name="parallel-alpha", - labels={"branch": "alpha", "issue": "123"}, -) -beta = pyro.create_workspace( - environment="debian:12", - seed_path="./repo", - name="parallel-beta", - labels={"branch": "beta", "issue": "456"}, -) - -pyro.write_workspace_file(alpha["workspace_id"], "branch.txt", text="alpha\n") -pyro.write_workspace_file(beta["workspace_id"], "branch.txt", text="beta\n") -pyro.update_workspace(alpha["workspace_id"], labels={"branch": "alpha", "owner": "alice"}) -pyro.list_workspaces() -pyro.delete_workspace(alpha["workspace_id"]) -pyro.delete_workspace(beta["workspace_id"]) -``` +1. Create one workspace per issue or branch with a human-friendly name and + labels. +2. Mutate each workspace independently. +3. Rediscover the right workspace later with `workspace_list`. +4. Update metadata when ownership or issue mapping changes. +5. Delete each workspace independently when its task is done. The important proof here is operational, not syntactic: names, labels, list ordering, and file contents stay isolated even when multiple workspaces are diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index f302974..3550143 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -12,31 +12,15 @@ Use this flow when the agent has to reproduce a bug, patch files without shell quoting tricks, rerun the failing command, diff the result, export the fix, and reset back to baseline. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./broken-repro") -workspace_id = str(created["workspace_id"]) - -pyro.exec_workspace(workspace_id, command="sh check.sh") -pyro.read_workspace_file(workspace_id, "message.txt") -pyro.apply_workspace_patch( - workspace_id, - patch="--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n", -) -pyro.exec_workspace(workspace_id, command="sh check.sh") -pyro.diff_workspace(workspace_id) -pyro.export_workspace(workspace_id, "message.txt", output_path="./message.txt") -pyro.reset_workspace(workspace_id) -pyro.delete_workspace(workspace_id) -``` - -Canonical MCP/chat example: - -- [examples/openai_responses_workspace_core.py](../../examples/openai_responses_workspace_core.py) +1. Create one workspace from the broken repro seed. +2. Run the failing command. +3. Inspect the broken file with structured file reads. +4. Apply the fix with `workspace_patch_apply`. +5. Rerun the failing command in the same workspace. +6. Diff and export the changed result. +7. Reset to baseline and delete the workspace. This is the main `workspace-core` story: model-native file ops, repeatable exec, structured diff, explicit export, and reset-over-repair. diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index eabe981..1d8d12e 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -11,30 +11,14 @@ make smoke-review-eval Use this flow when an agent needs to read a checklist interactively, run an evaluation script, checkpoint or reset its changes, and export the final report. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./review-fixture") -workspace_id = str(created["workspace_id"]) - -pyro.create_snapshot(workspace_id, "pre-review") -shell = pyro.open_shell(workspace_id) -pyro.write_shell(workspace_id, shell["shell_id"], input="cat CHECKLIST.md") -pyro.read_shell( - workspace_id, - shell["shell_id"], - plain=True, - wait_for_idle_ms=300, -) -pyro.close_shell(workspace_id, shell["shell_id"]) -pyro.exec_workspace(workspace_id, command="sh review.sh") -pyro.export_workspace(workspace_id, "review-report.txt", output_path="./review-report.txt") -pyro.reset_workspace(workspace_id, snapshot="pre-review") -pyro.delete_workspace(workspace_id) -``` +1. Create a named snapshot before the review starts. +2. Open a readable PTY shell and inspect the checklist interactively. +3. Run the review or evaluation script in the same workspace. +4. Export the final report. +5. Reset back to the snapshot if the review branch goes sideways. +6. Delete the workspace when the evaluation is done. This is the stable shell-facing story: readable PTY output for chat loops, checkpointed evaluation, explicit export, and reset when a review branch goes diff --git a/docs/use-cases/untrusted-inspection.md b/docs/use-cases/untrusted-inspection.md index a089faa..6a7b85b 100644 --- a/docs/use-cases/untrusted-inspection.md +++ b/docs/use-cases/untrusted-inspection.md @@ -11,24 +11,13 @@ make smoke-untrusted-inspection Use this flow when the agent needs to inspect suspicious code or an unfamiliar repo without granting more capabilities than necessary. -Canonical SDK flow: +Chat-host recipe: -```python -from pyro_mcp import Pyro - -pyro = Pyro() -created = pyro.create_workspace(environment="debian:12", seed_path="./suspicious-repo") -workspace_id = str(created["workspace_id"]) - -pyro.list_workspace_files(workspace_id, path="/workspace", recursive=True) -pyro.read_workspace_file(workspace_id, "suspicious.sh") -pyro.exec_workspace( - workspace_id, - command="sh -lc \"grep -n 'curl' suspicious.sh > inspection-report.txt\"", -) -pyro.export_workspace(workspace_id, "inspection-report.txt", output_path="./inspection-report.txt") -pyro.delete_workspace(workspace_id) -``` +1. Create one workspace from the suspicious repo seed. +2. Inspect the tree with structured file listing and file reads. +3. Run the smallest possible command that produces the inspection report. +4. Export only the report the agent chose to materialize. +5. Delete the workspace when inspection is complete. This recipe stays offline-by-default, uses only explicit file reads and execs, and exports only the inspection report the agent chose to materialize. diff --git a/docs/vision.md b/docs/vision.md index cdf852c..2192cce 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -1,16 +1,19 @@ # Vision -`pyro-mcp` should become the disposable sandbox where an agent can do real -development work safely, repeatedly, and reproducibly. +`pyro-mcp` should become the disposable MCP workspace for chat-based coding +agents. -That is a different product from a generic VM wrapper, a secure CI runner, or a -task queue with better isolation. +That is a different product from a generic VM wrapper, a secure CI runner, or +an SDK-first platform. + +`pyro-mcp` currently has no users. That means we can still make breaking +changes freely while we shape the chat-host path into the right product. ## Core Thesis The goal is not just to run one command in a microVM. -The goal is to give an LLM or coding agent a bounded workspace where it can: +The goal is to give a chat-hosted coding agent a bounded workspace where it can: - inspect a repo - install dependencies @@ -23,6 +26,25 @@ The goal is to give an LLM or coding agent a bounded workspace where it can: The sandbox is the execution boundary for agentic software work. +## Current Product Focus + +The product path should be obvious and narrow: + +- Claude Code +- Codex +- OpenCode +- Linux `x86_64` with KVM + +The happy path is: + +1. prove the host with the terminal companion commands +2. run `pyro mcp serve` +3. connect a chat host +4. work through one disposable workspace per task + +The repo can contain lower-level building blocks, but they should not drive the +product story. + ## What This Is Not `pyro-mcp` should not drift into: @@ -32,9 +54,10 @@ The sandbox is the execution boundary for agentic software work. - a generic CI job runner - a scheduler or queueing platform - a broad VM orchestration product +- an SDK product that happens to have an MCP server on the side -Those products optimize for queued work, throughput, retries, matrix builds, and -shared infrastructure. +Those products optimize for queued work, throughput, retries, matrix builds, or +library ergonomics. `pyro-mcp` should optimize for agent loops: @@ -57,10 +80,15 @@ Any sandbox product starts to look like CI if the main abstraction is: That shape is useful, but it is not the center of the vision. To stay aligned, the primary abstraction should be a workspace the agent -inhabits, not a job the agent submits. +inhabits from a chat host, not a job the agent submits to a runner. ## Product Principles +### Chat Hosts First + +The product should be shaped around the MCP path used from chat interfaces. +Everything else is there to support, debug, or build that path. + ### Workspace-First The default mental model should be "open a disposable workspace" rather than @@ -85,11 +113,6 @@ Anything that crosses the host boundary should be intentional and visible: Agents should be able to checkpoint, reset, and retry cheaply. Disposable state is a feature, not a limitation. -### Same Contract Across Surfaces - -CLI, Python, and MCP should expose the same underlying workspace model so the -product feels coherent no matter how it is consumed. - ### Agent-Native Observability The sandbox should expose the things an agent actually needs to reason about: @@ -101,10 +124,16 @@ The sandbox should expose the things an agent actually needs to reason about: - readiness - exported results -## The Shape Of An LLM-First Sandbox +## The Shape Of The Product -The strongest future direction is a small, agent-native contract built around -workspaces, shells, files, services, and reset. +The strongest direction is a small chat-facing contract built around: + +- one MCP server +- one disposable workspace model +- structured file inspection and edits +- repeated commands in the same sandbox +- service lifecycle when the workflow needs it +- reset as a first-class workflow primitive Representative primitives: @@ -114,95 +143,57 @@ Representative primitives: - `workspace.sync_push` - `workspace.export` - `workspace.diff` -- `workspace.snapshot` - `workspace.reset` +- `workspace.exec` - `shell.open` - `shell.read` - `shell.write` -- `shell.signal` -- `shell.close` -- `workspace.exec` - `service.start` - `service.status` - `service.logs` -- `service.stop` -These names are illustrative, not a committed public API. - -The important point is the interaction model: - -- a shell session is interactive state inside the sandbox -- a workspace is durable for the life of the task -- services are first-class, not accidental background jobs -- reset is a core workflow primitive +These names are illustrative, not a promise that every lower-level repo surface +should be treated as equally stable or equally important. ## Interactive Shells And Disk Operations Interactive shells are aligned with the vision because they make the agent feel present inside the sandbox rather than reduced to one-shot job submission. -That does not mean `pyro-mcp` should become a raw SSH replacement. The shell -should sit inside a higher-level workspace model with structured file, service, -diff, and reset operations around it. +They should remain subordinate to the workspace model, not replace it with a +raw SSH story. -Disk-level operations are also useful, but they should remain supporting tools. -They are good for: +Disk-level operations are useful for: - fast workspace seeding - snapshotting - offline inspection -- diffing - export/import without a full boot -They should not become the primary product identity. If the center of the -product becomes "operate on VM disks", it will read as image tooling rather -than an agent workspace. +They should remain supporting tools rather than the product identity. ## What To Build Next -Features should be prioritized in this order: +Features should keep reinforcing the chat-host path in this order: -1. Repeated commands in one persistent workspace -2. Interactive shell sessions with PTY semantics -3. Structured workspace sync and export -4. Service lifecycle and readiness checks -5. Snapshot and reset workflows -6. Explicit secrets and network policy -7. Secondary disk-level import/export and inspection tools +1. make the first chat-host setup painfully obvious +2. make the recipe-backed workflows feel trivial from chat +3. keep the smoke pack trustworthy enough to gate the advertised stories +4. keep the terminal companion path good enough to debug what the chat sees +5. let lower-level repo surfaces move freely when the chat-host product needs it The completed workspace GA roadmap lives in [roadmap/task-workspace-ga.md](roadmap/task-workspace-ga.md). -The next implementation milestones that make those workflows feel natural from -chat-driven LLM interfaces live in +The follow-on milestones that make the chat-host path clearer live in [roadmap/llm-chat-ergonomics.md](roadmap/llm-chat-ergonomics.md). -## Naming Guidance - -Prefer language that reinforces the workspace model: - -- `workspace` -- `sandbox` -- `shell` -- `service` -- `snapshot` -- `reset` - -Avoid centering language that makes the product feel like CI infrastructure: - -- `job` -- `runner` -- `pipeline` -- `worker` -- `queue` -- `build matrix` - ## Litmus Test When evaluating a new feature, ask: -"Does this help an agent inhabit a safe disposable workspace and do real -software work inside it?" +"Does this make Claude Code, Codex, or OpenCode feel more natural and powerful +when they work inside a disposable sandbox?" -If the better description is "it helps submit, schedule, and report jobs", the -feature is probably pushing the product in the wrong direction. +If the better description is "it helps build a broader VM toolkit or SDK", it +is probably pushing the product in the wrong direction. diff --git a/pyproject.toml b/pyproject.toml index 2f078d7..b541162 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "pyro-mcp" version = "4.0.0" -description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." +description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } authors = [ @@ -9,7 +9,7 @@ authors = [ ] requires-python = ">=3.12" classifiers = [ - "Development Status :: 5 - Production/Stable", + "Development Status :: 4 - Beta", "Environment :: Console", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 47df4ee..c688984 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -639,18 +639,23 @@ class _HelpFormatter( def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( - "Run stable one-shot and persistent workspace workflows on supported " - "Linux x86_64 KVM hosts." + "Validate the host and serve disposable MCP workspaces for chat-based " + "coding agents on supported Linux x86_64 KVM hosts." ), epilog=dedent( """ - Suggested first run: + Suggested zero-to-hero path: pyro doctor pyro env list pyro env pull debian:12 pyro run debian:12 -- git --version + pyro mcp serve - Continue into the stable workspace path after that: + Connect a chat host after that: + claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve + codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve + + If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes pyro workspace exec WORKSPACE_ID -- cat note.txt @@ -661,8 +666,6 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace export WORKSPACE_ID note.txt --output ./note.txt - - Use `pyro mcp serve` only after the CLI validation path works. """ ), formatter_class=_HelpFormatter, @@ -760,8 +763,8 @@ def _build_parser() -> argparse.ArgumentParser: help="Run the MCP server.", description=( "Run the MCP server after you have already validated the host and " - "guest execution with `pyro doctor` and `pyro run`. Bare `pyro " - "mcp serve` now starts the recommended `workspace-core` profile." + "guest execution with `pyro doctor` and `pyro run`. This is the " + "main product path for Claude Code, Codex, and OpenCode." ), epilog=dedent( """ @@ -790,11 +793,11 @@ def _build_parser() -> argparse.ArgumentParser: Profiles: workspace-core: default for normal persistent chat editing vm-run: smallest one-shot-only surface - workspace-full: advanced 4.x opt-in surface for shells, services, + workspace-full: larger opt-in surface for shells, services, snapshots, secrets, network policy, and disk tools - Use --profile workspace-full only when the host truly needs the full - advanced workspace surface. + Use --profile workspace-full only when the host truly needs those + extra workspace capabilities. """ ), formatter_class=_HelpFormatter, @@ -806,7 +809,7 @@ def _build_parser() -> argparse.ArgumentParser: help=( "Expose only one model-facing tool profile. `workspace-core` is " "the default and recommended first profile for most chat hosts; " - "`workspace-full` is the explicit advanced opt-in surface." + "`workspace-full` is the larger opt-in profile." ), ) @@ -888,7 +891,7 @@ def _build_parser() -> argparse.ArgumentParser: "workspace", help="Manage persistent workspaces.", description=( - "Use the stable workspace contract when you need one sandbox to stay alive " + "Use the workspace model when you need one sandbox to stay alive " "across repeated exec, shell, service, diff, export, snapshot, and reset calls." ), epilog=dedent( @@ -1818,7 +1821,7 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace service stop WORKSPACE_ID app Use `--ready-file` by default in the curated Debian environments. `--ready-command` - remains available as an escape hatch. + remains available when the workflow needs a custom readiness check. """ ), formatter_class=_HelpFormatter, diff --git a/tests/test_cli.py b/tests/test_cli.py index aaef8d1..7b276ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,17 +26,20 @@ def test_cli_help_guides_first_run() -> None: parser = cli._build_parser() help_text = parser.format_help() - assert "Suggested first run:" in help_text + assert "Suggested zero-to-hero path:" in help_text assert "pyro doctor" in help_text assert "pyro env list" in help_text assert "pyro env pull debian:12" in help_text assert "pyro run debian:12 -- git --version" in help_text - assert "Continue into the stable workspace path after that:" in help_text + assert "pyro mcp serve" in help_text + assert "Connect a chat host after that:" in help_text + assert "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text + assert "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text + assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in help_text assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text - assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text def test_cli_subcommand_help_includes_examples_and_guidance() -> None: @@ -69,10 +72,10 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "vm-run" in mcp_help assert "recommended first profile for most chat hosts" in mcp_help assert "workspace-core: default for normal persistent chat editing" in mcp_help - assert "workspace-full: advanced 4.x opt-in surface" in mcp_help + assert "workspace-full: larger opt-in surface" in mcp_help workspace_help = _subparser_choice(parser, "workspace").format_help() - assert "stable workspace contract" in workspace_help + assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help assert "pyro workspace create debian:12 --seed-path ./repo" in workspace_help assert "--id-only" in workspace_help assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help @@ -2822,9 +2825,9 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert claude_cmd in readme assert codex_cmd in readme assert "examples/opencode_mcp_config.json" in readme - assert "recommended first profile for normal persistent chat editing" in readme + assert "Bare `pyro mcp serve` starts `workspace-core`" in readme - assert "## Chat Host Quickstart" in install + assert "## 5. Connect a chat host" in install assert "uvx --from pyro-mcp pyro mcp serve" in install assert claude_cmd in install assert codex_cmd in install @@ -2833,14 +2836,14 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert claude_cmd in first_run assert codex_cmd in first_run - assert "Bare `pyro mcp serve` now starts `workspace-core`." in integrations + assert ( + "Bare `pyro mcp serve` starts `workspace-core`. That is the product path." + in integrations + ) assert "examples/claude_code_mcp.md" in integrations assert "examples/codex_mcp.md" in integrations assert "examples/opencode_mcp_config.json" in integrations - assert ( - '`Pyro.create_server()` for most chat hosts now that `workspace-core` ' - "is the default profile" in integrations - ) + assert "That is the product path." in integrations assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config assert "Use the host-specific examples first when they apply:" in mcp_config @@ -2882,7 +2885,7 @@ def test_content_only_read_docs_are_aligned() -> None: assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in readme assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in install assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in first_run - assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' in first_run + assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run def test_cli_workspace_shell_write_signal_close_json( From 9b9b83ebebd9664afb6cecb5f495531e7060d494 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 15:18:02 -0300 Subject: [PATCH 2/8] Add post-4.0 chat product roadmap Extend the chat ergonomics roadmap now that the core workspace and MCP path are in place for the narrowed chat-host persona. Add the next planned phase around project-aware chat startup, host bootstrap and repair, reviewable agent output, opinionated use-case modes, and faster daily loops so the roadmap keeps pushing toward a repo-aware daily tool instead of a generic VM or SDK story. Document the constraints explicitly: optimize the MCP/chat-host path first, keep disk tools secondary, and take advantage of the current no-users-yet window to make breaking product-shaping changes when needed. --- docs/roadmap/llm-chat-ergonomics.md | 43 ++++++++++++-- .../4.1.0-project-aware-chat-startup.md | 56 ++++++++++++++++++ .../4.2.0-host-bootstrap-and-repair.md | 53 +++++++++++++++++ .../4.3.0-reviewable-agent-output.md | 54 ++++++++++++++++++ .../4.4.0-opinionated-use-case-modes.md | 56 ++++++++++++++++++ .../4.5.0-faster-daily-loops.md | 57 +++++++++++++++++++ 6 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 93fa8e4..fa66522 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -8,10 +8,13 @@ interface. Current baseline is `4.0.0`: -- the stable workspace contract exists across CLI, SDK, and MCP -- one-shot `pyro run` still exists as the narrow entrypoint +- `pyro mcp serve` is now the default product entrypoint +- `workspace-core` is now the default MCP profile +- one-shot `pyro run` still exists as the terminal companion path - workspaces already support seeding, sync push, exec, export, diff, snapshots, reset, services, PTY shells, secrets, network policy, and published ports +- host-specific onramps exist for Claude Code, Codex, and OpenCode +- the five documented use cases are now recipe-backed and smoke-tested - stopped-workspace disk tools now exist, but remain explicitly secondary ## What "Trivial In Chat" Means @@ -33,9 +36,16 @@ More concretely, the model should not need to: - choose from an unnecessarily large tool surface when a smaller profile would work -The remaining UX friction for a technically strong new user is now narrower: +The next gaps for the narrowed persona are now product-shaping rather than raw +capability gaps: -- no major chat-host ergonomics gaps remain in the current roadmap +- starting from the current repo still needs to feel native from the first chat +- host setup and repair still lean on manual commands and config copying +- reviewing what the agent actually did still requires digging through several + surfaces +- the five use cases exist as docs and smokes, not yet as explicit product + modes +- daily reset and retry loops can still feel heavier than they should ## Locked Decisions @@ -43,9 +53,16 @@ The remaining UX friction for a technically strong new user is now narrower: or runner abstractions - keep disk tools secondary and do not make them the main chat-facing surface - prefer narrow tool profiles and structured outputs over more raw shell calls -- capability milestones should update CLI, SDK, and MCP together +- optimize the MCP/chat-host path first and keep the CLI companion path good + enough to validate and debug it +- lower-level SDK and repo substrate work can continue, but they should not + drive milestone scope or naming - CLI-only ergonomics are allowed when the SDK and MCP surfaces already have the structured behavior natively +- prioritize repo-aware startup, trust, and daily-loop speed before adding more + low-level workspace surface area +- breaking changes are acceptable while there are still no users and the + chat-host product is still being shaped - every milestone below must also update docs, help text, runnable examples, and at least one real smoke scenario @@ -62,6 +79,11 @@ The remaining UX friction for a technically strong new user is now narrower: 9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.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 +12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Planned +13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.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 +16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned Completed so far: @@ -95,7 +117,11 @@ Completed so far: Planned next: -- no further chat-ergonomics milestones are currently planned in this roadmap. +- [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) +- [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) +- [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) +- [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) +- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) ## Expected Outcome @@ -117,3 +143,8 @@ The intended model-facing shape is: - human-mode content reads are copy-paste safe - the default bare MCP server entrypoint matches the recommended narrow profile - the five core use cases are documented and smoke-tested end to end +- starting from the current repo feels native from the first chat-host setup +- supported hosts can be connected or repaired without manual config spelunking +- users can review one concise summary of what the agent changed and ran +- the main workflows feel like named modes instead of one giant reference +- reset and retry loops are fast enough to encourage daily use diff --git a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md new file mode 100644 index 0000000..38a86fd --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md @@ -0,0 +1,56 @@ +# `4.1.0` Project-Aware Chat Startup + +Status: Planned + +## Goal + +Make "current repo to disposable sandbox" the default story for the narrowed +chat-host user, without requiring manual workspace seeding choreography first. + +## Public API Changes + +The chat entrypoint should gain one documented project-aware startup path: + +- `pyro mcp serve` should accept an explicit local project source, such as the + current checkout +- the product path should optionally support a clean-clone source, such as a + repo URL, when the user is not starting from a local checkout +- the first useful chat turn should not depend on manually teaching + `workspace create ... --seed-path ...` before the host can do real work + +Exact flag names can still change, but the product needs one obvious "use this +repo" path and one obvious "start from that repo" path. + +## Implementation Boundaries + +- keep host crossing explicit; do not silently mutate the user's checkout +- prefer local checkout seeding first, because that is the most natural daily + chat path +- preserve existing explicit sync, export, diff, and reset primitives rather + than inventing a hidden live-sync layer +- keep the startup story compatible with the existing `workspace-core` product + path + +## Non-Goals + +- no generic SCM integration platform +- no background multi-repo workspace manager +- no always-on bidirectional live sync between host checkout and guest + +## Acceptance Scenarios + +- from a repo root, a user can connect Claude Code, Codex, or OpenCode and the + first workspace starts from that repo without extra terminal choreography +- from outside a repo checkout, a user can still start from a documented clean + source such as a repo URL +- the README and install docs can teach a repo-aware chat flow before the + manual terminal workspace flow + +## Required Repo Updates + +- README, install docs, first-run docs, integrations docs, and public contract + updated to show the repo-aware chat startup path +- help text updated so the repo-aware startup path is visible from `pyro` and + `pyro mcp serve --help` +- at least one recipe and one real smoke scenario updated to validate the new + startup story diff --git a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md new file mode 100644 index 0000000..4ccf34b --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md @@ -0,0 +1,53 @@ +# `4.2.0` Host Bootstrap And Repair + +Status: Planned + +## Goal + +Make supported chat hosts feel one-command to connect and easy to repair when a +local config drifts or the product changes shape. + +## Public API Changes + +The CLI should grow a small host-helper surface for the supported chat hosts: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` + +The exact names can still move, but the product needs a first-class bootstrap +and repair path for Claude Code, Codex, and OpenCode. + +## Implementation Boundaries + +- host helpers should wrap the same `pyro mcp serve` entrypoint rather than + introduce per-host runtime behavior +- config changes should remain inspectable and predictable +- support both installed-package and `uvx`-style usage where that materially + reduces friction +- keep the host helper story narrow to the current supported hosts + +## Non-Goals + +- no GUI installer or onboarding wizard +- no attempt to support every possible MCP-capable editor or chat shell +- no hidden network service or account-based control plane + +## Acceptance Scenarios + +- a new Claude Code or Codex user can connect `pyro` with one command +- an OpenCode user can print or materialize a correct config without hand-writing + JSON +- a user with a stale or broken local host config can run one repair or doctor + flow instead of debugging MCP setup manually + +## Required Repo Updates + +- new host-helper docs and examples for all supported chat hosts +- README, install docs, and integrations docs updated to prefer the helper + flows when available +- help text updated with exact connect and repair commands +- runnable verification or smoke coverage that proves the shipped host-helper + examples stay current diff --git a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md new file mode 100644 index 0000000..838a28c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md @@ -0,0 +1,54 @@ +# `4.3.0` Reviewable Agent Output + +Status: Planned + +## Goal + +Make it easy for a human to review what the agent actually did inside the +sandbox without manually reconstructing the session from diffs, logs, and raw +artifacts. + +## Public API Changes + +The product should expose a concise workspace review surface, for example: + +- `pyro workspace summary WORKSPACE_ID` +- `workspace_summary` on the MCP side +- structured JSON plus a short human-readable summary view + +The summary should cover the things a chat-host user cares about: + +- commands run +- files changed +- diff or patch summary +- services started +- artifacts exported +- final workspace outcome + +## Implementation Boundaries + +- prefer concise review surfaces over raw event firehoses +- keep raw logs, diffs, and exported files available as drill-down tools +- summarize only the sandbox activity the product can actually observe +- make the summary good enough to paste into a chat, bug report, or PR comment + +## Non-Goals + +- no full compliance or audit product +- no attempt to summarize the model's hidden reasoning +- no remote storage backend for session history + +## Acceptance Scenarios + +- after a repro-fix or review-eval run, a user can inspect one summary and + understand what changed and what to review next +- the summary is useful enough to accompany exported patches or artifacts +- unsafe-inspection and review-eval flows become easier to trust because the + user can review agent-visible actions in one place + +## Required Repo Updates + +- public contract, help text, README, and recipe docs updated with the new + summary path +- at least one host-facing example showing how to ask for or export the summary +- at least one real smoke scenario validating the review surface end to end diff --git a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md new file mode 100644 index 0000000..c57f951 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md @@ -0,0 +1,56 @@ +# `4.4.0` Opinionated Use-Case Modes + +Status: Planned + +## Goal + +Stop making chat-host users think in terms of one giant workspace surface and +let them start from a small mode that matches the job they want the agent to do. + +## Public API Changes + +The chat entrypoint should gain named use-case modes, for example: + +- `pyro mcp serve --mode repro-fix` +- `pyro mcp serve --mode inspect` +- `pyro mcp serve --mode cold-start` +- `pyro mcp serve --mode review-eval` + +Modes should narrow the product story by selecting the right defaults for: + +- tool surface +- workspace bootstrap behavior +- docs and example prompts +- expected export and review outputs + +Parallel workspace use should come from opening more than one named workspace +inside the same mode, not from introducing a scheduler or queue abstraction. + +## Implementation Boundaries + +- build modes on top of the existing `workspace-core` and `workspace-full` + capabilities instead of inventing separate backends +- keep the mode list short and mapped to the documented use cases +- make modes visible from help text, host helpers, and recipe docs together +- let users opt out to the generic workspace path when the mode is too narrow + +## Non-Goals + +- no user-defined mode DSL +- no hidden host-specific behavior for the same mode name +- no CI-style pipelines, matrix builds, or queueing abstractions + +## Acceptance Scenarios + +- a new user can pick one mode and avoid reading the full workspace surface + before starting +- the documented use cases map cleanly to named entry modes +- parallel issue or PR work feels like "open another workspace in the same + mode", not "submit another job" + +## Required Repo Updates + +- help text, README, install docs, integrations docs, and use-case recipes + updated to teach the named modes +- host-specific setup docs updated so supported hosts can start in a named mode +- at least one smoke scenario proving a mode-specific happy path end to end diff --git a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md new file mode 100644 index 0000000..01c765c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md @@ -0,0 +1,57 @@ +# `4.5.0` Faster Daily Loops + +Status: Planned + +## Goal + +Make the day-to-day chat-host loop feel cheap enough that users reach for it +for normal work, not only for special high-isolation tasks. + +## Public API Changes + +The product should add an explicit fast-path for repeated local use, such as: + +- a prewarm or prepare path for the recommended environment and runtime +- a clearer fast reset or retry path for repeated repro-fix loops +- visible diagnostics for cache, prewarm, or ready-state health + +The exact command names can still move, but the user-visible story needs to be: + +- set the machine up once +- reconnect quickly +- create or reset a workspace cheaply +- keep iterating without redoing heavy setup work + +## Implementation Boundaries + +- optimize local-first loops on one machine before thinking about remote + execution +- focus on startup, create, reset, and retry latency rather than queue + throughput +- keep the fast path compatible with the repo-aware startup story and the + supported chat hosts +- prefer explicit caching and prewarm semantics over hidden long-running + daemons + +## Non-Goals + +- no cloud prewarm service +- no scheduler or queueing layer +- no daemon requirement for normal daily use + +## Acceptance Scenarios + +- after the first setup, entering the chat-host path again does not feel like + redoing the whole product onboarding +- reset and retry become cheap enough to recommend as the default repro-fix + workflow +- docs can present `pyro` as a daily coding-agent tool, not only as a special + heavy-duty sandbox + +## Required Repo Updates + +- docs updated to show the recommended daily-use fast path +- diagnostics and help text updated so the user can tell whether the machine is + already warm and ready +- at least one repeat-loop smoke or benchmark-style verification scenario + added to prevent regressions in the daily workflow From 535efc6919ca613dc5fdfcbfe836928ded76546d Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 15:51:47 -0300 Subject: [PATCH 3/8] Add project-aware chat startup defaults Make repo-root chat startup native by letting MCP servers carry a default project source for workspace creation. When a chat host starts from a Git checkout, workspace_create can now omit seed_path and inherit the server startup source; explicit --project-path and clean-clone --repo-url/--repo-ref paths are supported as fallbacks. Add project startup resolution and materialization, surface origin_kind/origin_ref in workspace_seed, update chat-host docs and the repro/fix smoke to use project-aware workspace creation, and switch dist-check to uv run pyro so verification stays stable after uv reinstalls. Validated with uv lock, focused startup/server/CLI pytest coverage, UV_CACHE_DIR=.uv-cache make check, UV_CACHE_DIR=.uv-cache make dist-check, and real guest-backed smokes for both explicit project_path and bare repo-root auto-detection. --- CHANGELOG.md | 12 ++ Makefile | 14 +- README.md | 29 +++- docs/first-run.md | 19 ++- docs/install.md | 22 ++- docs/integrations.md | 32 +++- docs/public-contract.md | 9 +- docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.1.0-project-aware-chat-startup.md | 2 +- docs/use-cases/repro-fix-loop.md | 17 +- examples/claude_code_mcp.md | 10 ++ examples/codex_mcp.md | 10 ++ examples/mcp_client_config.md | 4 + pyproject.toml | 2 +- src/pyro_mcp/api.py | 96 ++++++++++- src/pyro_mcp/cli.py | 59 ++++++- src/pyro_mcp/contract.py | 8 +- src/pyro_mcp/project_startup.py | 149 ++++++++++++++++++ src/pyro_mcp/server.py | 18 ++- src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/vm_manager.py | 50 +++++- src/pyro_mcp/workspace_use_case_smokes.py | 43 ++++- tests/test_api.py | 75 +++++++++ tests/test_cli.py | 58 +++++-- tests/test_project_startup.py | 115 ++++++++++++++ tests/test_server.py | 138 ++++++++++++++++ tests/test_workspace_use_case_smokes.py | 30 ++++ uv.lock | 2 +- 28 files changed, 968 insertions(+), 67 deletions(-) create mode 100644 src/pyro_mcp/project_startup.py create mode 100644 tests/test_project_startup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 863a6d0..70987e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.1.0 + +- Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can + auto-detect the current Git checkout and let `workspace_create` omit + `seed_path` safely. +- Added explicit fallback startup flags for chat hosts that do not preserve the + server working directory: `--project-path`, `--repo-url`, `--repo-ref`, and + `--no-project-source`. +- Extended workspace seed metadata with startup origin fields so chat-facing + workspace creation can show whether a workspace came from a manual seed path, + the current project, or a clean cloned repo source. + ## 4.0.0 - Flipped the default MCP/server profile from `workspace-full` to diff --git a/Makefile b/Makefile index 8c71e5b..1211c8f 100644 --- a/Makefile +++ b/Makefile @@ -82,13 +82,13 @@ test: check: lint typecheck test dist-check: - .venv/bin/pyro --version - .venv/bin/pyro --help >/dev/null - .venv/bin/pyro mcp --help >/dev/null - .venv/bin/pyro run --help >/dev/null - .venv/bin/pyro env list >/dev/null - .venv/bin/pyro env inspect debian:12 >/dev/null - .venv/bin/pyro doctor >/dev/null + uv run pyro --version + uv run pyro --help >/dev/null + uv run pyro mcp --help >/dev/null + uv run pyro run --help >/dev/null + uv run pyro env list >/dev/null + uv run pyro env inspect debian:12 >/dev/null + uv run pyro doctor >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ diff --git a/README.md b/README.md index 65e2dfc..848fc29 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.0.0: [CHANGELOG.md#400](CHANGELOG.md#400) +- What's new in 4.1.0: [CHANGELOG.md#410](CHANGELOG.md#410) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -76,7 +76,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.0.0 +Catalog version: 4.1.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -96,13 +96,26 @@ for the guest image. ## Chat Host Quickstart After the quickstart works, the intended next step is to connect a chat host. -Bare `pyro mcp serve` starts `workspace-core`, which is the default product -path. +From a repo root, bare `pyro mcp serve` starts `workspace-core`, auto-detects +the current Git checkout, and lets the first `workspace_create` omit +`seed_path`. ```bash uvx --from pyro-mcp pyro mcp serve ``` +If the host does not preserve the server working directory, use: + +```bash +uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are starting outside a local checkout, use a clean clone source: + +```bash +uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + Copy-paste host-specific starts: - Claude Code: [examples/claude_code_mcp.md](examples/claude_code_mcp.md) @@ -136,6 +149,10 @@ OpenCode `opencode.json` snippet: } ``` +If OpenCode launches the server from an unexpected cwd, add +`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command +array. + If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with `pyro` in the same command or config shape. @@ -146,7 +163,9 @@ snapshots, secrets, network policy, or disk tools. 1. Validate the host with `pyro doctor`. 2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. -3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve`. +3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo + root, or use `--project-path` / `--repo-url` when cwd is not the source of + truth. 4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). `repro-fix-loop` is the shortest chat-first story. 5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path diff --git a/docs/first-run.md b/docs/first-run.md index 4ac064f..6b40344 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.0.0 +Catalog version: 4.1.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -71,13 +71,26 @@ streams, so they may appear in either order in terminals or capture tools. Use ## 5. Start the MCP server -Bare `pyro mcp serve` now starts `workspace-core`, which is the intended chat -path: +Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`: ```bash $ uvx --from pyro-mcp pyro mcp serve ``` +If the host does not preserve the server working directory: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are outside a local checkout: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + ## 6. Connect a chat host Claude Code: diff --git a/docs/install.md b/docs/install.md index 6ef5649..cb02312 100644 --- a/docs/install.md +++ b/docs/install.md @@ -92,7 +92,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.0.0 +Catalog version: 4.1.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -140,13 +140,26 @@ deterministic structured result. ## 5. Connect a chat host -Bare `pyro mcp serve` now starts `workspace-core`, which is the default -product path. +Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`. ```bash uvx --from pyro-mcp pyro mcp serve ``` +If the host does not preserve the server working directory, use: + +```bash +uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are starting outside a local checkout, use a clean clone source: + +```bash +uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git +``` + Copy-paste host-specific starts: - Claude Code setup: [claude_code_mcp.md](../examples/claude_code_mcp.md) @@ -182,7 +195,8 @@ The intended user journey is: 1. validate the host with `pyro doctor` 2. pull `debian:12` 3. prove guest execution with `pyro run debian:12 -- git --version` -4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` +4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo + root, or use `--project-path` / `--repo-url` when needed 5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) 6. trust but verify with `make smoke-use-cases` diff --git a/docs/integrations.md b/docs/integrations.md index c3bbcb8..7c0a96b 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -15,12 +15,26 @@ through [install.md](install.md) or [first-run.md](first-run.md). ## Recommended Default -Bare `pyro mcp serve` starts `workspace-core`. That is the product path. +Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also +auto-detects the current Git checkout so the first `workspace_create` can omit +`seed_path`. That is the product path. ```bash pyro mcp serve ``` +If the host does not preserve cwd, fall back to: + +```bash +pyro mcp serve --project-path /abs/path/to/repo +``` + +If you are outside a repo checkout entirely, start from a clean clone source: + +```bash +pyro mcp serve --repo-url https://github.com/example/project.git +``` + Use `--profile workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools. @@ -33,6 +47,12 @@ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve claude mcp list ``` +If Claude Code launches the server from an unexpected cwd, use: + +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + Already installed: ```bash @@ -53,6 +73,12 @@ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve codex mcp list ``` +If Codex launches the server from an unexpected cwd, use: + +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + Already installed: ```bash @@ -87,6 +113,10 @@ Minimal `opencode.json` snippet: If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with `pyro` in the same config shape. +If OpenCode launches the server from an unexpected cwd, add +`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command +array. + ## Generic MCP Fallback Use this only when the host expects a plain `mcpServers` JSON config and does diff --git a/docs/public-contract.md b/docs/public-contract.md index 0a4de23..ae5c55e 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -76,10 +76,16 @@ pyro mcp serve What to expect: - bare `pyro mcp serve` starts `workspace-core` +- from a repo root, bare `pyro mcp serve` also auto-detects the current Git + checkout so `workspace_create` can omit `seed_path` - `workspace-core` is the default product path for chat hosts - `pyro mcp serve --profile workspace-full` explicitly opts into the larger tool surface - `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface +- `pyro mcp serve --project-path /abs/path/to/repo` is the fallback when the + host does not preserve cwd +- `pyro mcp serve --repo-url ... [--repo-ref ...]` starts from a clean clone + source instead of a local checkout Host-specific setup docs: @@ -111,7 +117,8 @@ Host-specific setup docs: That is enough for the normal persistent editing loop: -- create one workspace +- create one workspace, often without `seed_path` when the server already has a + project source - sync or seed repo content - inspect and edit files without shell quoting - run commands repeatedly in one sandbox diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index fa66522..6a40a81 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.0.0`: +Current baseline is `4.1.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -79,7 +79,7 @@ capability gaps: 9. [`3.10.0` Use-Case Smoke Trust And Recipe Fidelity](llm-chat-ergonomics/3.10.0-use-case-smoke-trust-and-recipe-fidelity.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 -12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Planned +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 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 @@ -114,10 +114,12 @@ Completed so far: config manually. - `4.0.0` flipped the default MCP/server profile to `workspace-core`, so the bare entrypoint now matches the recommended narrow chat-host profile across CLI, SDK, and package-level factories. +- `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect + the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit + `--project-path` and `--repo-url` fallbacks when cwd is not the source of truth. Planned next: -- [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) diff --git a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md index 38a86fd..e3e6986 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md +++ b/docs/roadmap/llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md @@ -1,6 +1,6 @@ # `4.1.0` Project-Aware Chat Startup -Status: Planned +Status: Done ## Goal diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index 3550143..fac4090 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -14,13 +14,16 @@ reset back to baseline. Chat-host recipe: -1. Create one workspace from the broken repro seed. -2. Run the failing command. -3. Inspect the broken file with structured file reads. -4. Apply the fix with `workspace_patch_apply`. -5. Rerun the failing command in the same workspace. -6. Diff and export the changed result. -7. Reset to baseline and delete the workspace. +1. Start the server from the repo root with bare `pyro mcp serve`, or use + `--project-path` if the host does not preserve cwd. +2. Create one workspace from that project-aware server without manually passing + `seed_path`. +3. Run the failing command. +4. Inspect the broken file with structured file reads. +5. Apply the fix with `workspace_patch_apply`. +6. Rerun the failing command in the same workspace. +7. Diff and export the changed result. +8. Reset to baseline and delete the workspace. This is the main `workspace-core` story: model-native file ops, repeatable exec, structured diff, explicit export, and reset-over-repair. diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index d62cae4..3dbc5d0 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -9,6 +9,9 @@ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve claude mcp list ``` +Run that from the repo root when you want the first `workspace_create` to start +from the current checkout automatically. + Already installed: ```bash @@ -16,6 +19,13 @@ claude mcp add pyro -- pyro mcp serve claude mcp list ``` +If Claude Code launches the server from an unexpected cwd, pin the project +explicitly: + +```bash +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index 6838a7d..aa38813 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -9,6 +9,9 @@ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve codex mcp list ``` +Run that from the repo root when you want the first `workspace_create` to start +from the current checkout automatically. + Already installed: ```bash @@ -16,6 +19,13 @@ codex mcp add pyro -- pyro mcp serve codex mcp list ``` +If Codex launches the server from an unexpected cwd, pin the project +explicitly: + +```bash +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 51d7419..10ee175 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -39,6 +39,10 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi } ``` +If the host does not preserve the server working directory and you want the +first `workspace_create` to start from a specific checkout, add +`"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list. + Profile progression: - `workspace-core`: the default and recommended first persistent chat profile diff --git a/pyproject.toml b/pyproject.toml index b541162..7bf1bc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.0.0" +version = "4.1.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 6dfc5fb..35feeb9 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -13,6 +13,12 @@ from pyro_mcp.contract import ( PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, ) +from pyro_mcp.project_startup import ( + ProjectStartupSource, + describe_project_startup_source, + materialize_project_startup_source, + resolve_project_startup_source, +) from pyro_mcp.vm_manager import ( DEFAULT_ALLOW_HOST_COMPAT, DEFAULT_MEM_MIB, @@ -39,6 +45,18 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile: return cast(McpToolProfile, profile) +def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str: + if startup_source is None: + return "Create and start a persistent workspace." + described_source = describe_project_startup_source(startup_source) + if described_source is None: + return "Create and start a persistent workspace." + return ( + "Create and start a persistent workspace. If `seed_path` is omitted, " + f"the server seeds from {described_source}." + ) + + class Pyro: """High-level facade over the ephemeral VM runtime.""" @@ -462,14 +480,31 @@ class Pyro: allow_host_compat=allow_host_compat, ) - def create_server(self, *, profile: McpToolProfile = "workspace-core") -> FastMCP: + def create_server( + self, + *, + profile: McpToolProfile = "workspace-core", + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, + ) -> FastMCP: """Create an MCP server for one of the stable public tool profiles. `workspace-core` is the default stable chat-host profile in 4.x. Use `profile="workspace-full"` only when the host truly needs the full - advanced workspace surface. + advanced workspace surface. By default, the server auto-detects the + nearest Git worktree root from its current working directory and uses + that source when `workspace_create` omits `seed_path`. `project_path`, + `repo_url`, and `no_project_source` override that behavior explicitly. """ normalized_profile = _validate_mcp_profile(profile) + startup_source = resolve_project_startup_source( + project_path=project_path, + repo_url=repo_url, + repo_ref=repo_ref, + no_project_source=no_project_source, + ) enabled_tools = set(_PROFILE_TOOLS[normalized_profile]) server = FastMCP(name="pyro_mcp") @@ -583,9 +618,56 @@ class Pyro: return self.reap_expired() if _enabled("workspace_create"): + workspace_create_description = _workspace_create_description(startup_source) + + def _create_workspace_from_server_defaults( + *, + environment: str, + vcpu_count: int, + mem_mib: int, + ttl_seconds: int, + network_policy: str, + allow_host_compat: bool, + seed_path: str | None, + secrets: list[dict[str, str]] | None, + name: str | None, + labels: dict[str, str] | None, + ) -> dict[str, Any]: + if seed_path is not None or startup_source is None: + return self.create_workspace( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network_policy=network_policy, + allow_host_compat=allow_host_compat, + seed_path=seed_path, + secrets=secrets, + name=name, + labels=labels, + ) + with materialize_project_startup_source(startup_source) as resolved_seed_path: + prepared_seed = self._manager._prepare_workspace_seed( # noqa: SLF001 + resolved_seed_path, + origin_kind=startup_source.kind, + origin_ref=startup_source.origin_ref, + ) + return self._manager.create_workspace( + environment=environment, + vcpu_count=vcpu_count, + mem_mib=mem_mib, + ttl_seconds=ttl_seconds, + network_policy=network_policy, + allow_host_compat=allow_host_compat, + secrets=secrets, + name=name, + labels=labels, + _prepared_seed=prepared_seed, + ) + if normalized_profile == "workspace-core": - @server.tool(name="workspace_create") + @server.tool(name="workspace_create", description=workspace_create_description) async def workspace_create_core( environment: str, vcpu_count: int = DEFAULT_VCPU_COUNT, @@ -596,8 +678,7 @@ class Pyro: name: str | None = None, labels: dict[str, str] | None = None, ) -> dict[str, Any]: - """Create and start a persistent workspace.""" - return self.create_workspace( + return _create_workspace_from_server_defaults( environment=environment, vcpu_count=vcpu_count, mem_mib=mem_mib, @@ -612,7 +693,7 @@ class Pyro: else: - @server.tool(name="workspace_create") + @server.tool(name="workspace_create", description=workspace_create_description) async def workspace_create_full( environment: str, vcpu_count: int = DEFAULT_VCPU_COUNT, @@ -625,8 +706,7 @@ class Pyro: name: str | None = None, labels: dict[str, str] | None = None, ) -> dict[str, Any]: - """Create and start a persistent workspace.""" - return self.create_workspace( + return _create_workspace_from_server_defaults( environment=environment, vcpu_count=vcpu_count, mem_mib=mem_mib, diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index c688984..4011d18 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -191,7 +191,16 @@ def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> N if isinstance(workspace_seed, dict): mode = str(workspace_seed.get("mode", "empty")) seed_path = workspace_seed.get("seed_path") - if isinstance(seed_path, str) and seed_path != "": + origin_kind = workspace_seed.get("origin_kind") + origin_ref = workspace_seed.get("origin_ref") + if isinstance(origin_kind, str) and isinstance(origin_ref, str) and origin_ref != "": + if origin_kind == "project_path": + print(f"Workspace seed: {mode} from project {origin_ref}") + elif origin_kind == "repo_url": + print(f"Workspace seed: {mode} from clean clone {origin_ref}") + else: + print(f"Workspace seed: {mode} from {origin_ref}") + elif isinstance(seed_path, str) and seed_path != "": print(f"Workspace seed: {mode} from {seed_path}") else: print(f"Workspace seed: {mode}") @@ -770,6 +779,8 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro mcp serve + pyro mcp serve --project-path . + pyro mcp serve --repo-url https://github.com/example/project.git pyro mcp serve --profile vm-run pyro mcp serve --profile workspace-full """ @@ -783,12 +794,15 @@ def _build_parser() -> argparse.ArgumentParser: description=( "Expose pyro tools over stdio for an MCP client. Bare `pyro mcp " "serve` now starts `workspace-core`, the recommended first profile " - "for most chat hosts." + "for most chat hosts. When launched from inside a Git checkout, it " + "also seeds the first workspace from that repo by default." ), epilog=dedent( """ Default and recommended first start: pyro mcp serve + pyro mcp serve --project-path . + pyro mcp serve --repo-url https://github.com/example/project.git Profiles: workspace-core: default for normal persistent chat editing @@ -796,6 +810,12 @@ def _build_parser() -> argparse.ArgumentParser: workspace-full: larger opt-in surface for shells, services, snapshots, secrets, network policy, and disk tools + Project-aware startup: + - bare `pyro mcp serve` auto-detects the nearest Git checkout + from the current working directory + - use --project-path when the host does not preserve cwd + - use --repo-url for a clean-clone source outside a local checkout + Use --profile workspace-full only when the host truly needs those extra workspace capabilities. """ @@ -812,6 +832,33 @@ def _build_parser() -> argparse.ArgumentParser: "`workspace-full` is the larger opt-in profile." ), ) + mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group() + mcp_source_group.add_argument( + "--project-path", + help=( + "Seed default workspaces from this local project path. If the path " + "is inside a Git checkout, pyro uses that repo root." + ), + ) + mcp_source_group.add_argument( + "--repo-url", + help=( + "Seed default workspaces from a clean host-side clone of this repo URL " + "when `workspace_create` omits `seed_path`." + ), + ) + mcp_serve_parser.add_argument( + "--repo-ref", + help="Optional branch, tag, or commit to checkout after cloning --repo-url.", + ) + mcp_serve_parser.add_argument( + "--no-project-source", + action="store_true", + help=( + "Disable automatic Git checkout detection from the current working " + "directory." + ), + ) run_parser = subparsers.add_parser( "run", @@ -2304,7 +2351,13 @@ def main() -> None: _print_prune_human(prune_payload) return if args.command == "mcp": - pyro.create_server(profile=args.profile).run(transport="stdio") + pyro.create_server( + profile=args.profile, + project_path=args.project_path, + repo_url=args.repo_url, + repo_ref=args.repo_ref, + no_project_source=bool(args.no_project_source), + ).run(transport="stdio") return if args.command == "run": command = _require_command(args.command_args) diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 714acf7..68ce11f 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -6,7 +6,13 @@ PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) -PUBLIC_CLI_MCP_SERVE_FLAGS = ("--profile",) +PUBLIC_CLI_MCP_SERVE_FLAGS = ( + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "create", "delete", diff --git a/src/pyro_mcp/project_startup.py b/src/pyro_mcp/project_startup.py new file mode 100644 index 0000000..102d631 --- /dev/null +++ b/src/pyro_mcp/project_startup.py @@ -0,0 +1,149 @@ +"""Server-scoped project startup source helpers for MCP chat flows.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Iterator, Literal + +ProjectStartupSourceKind = Literal["project_path", "repo_url"] + + +@dataclass(frozen=True) +class ProjectStartupSource: + """Server-scoped default source for workspace creation.""" + + kind: ProjectStartupSourceKind + origin_ref: str + resolved_path: Path | None = None + repo_ref: str | None = None + + +def _run_git(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + cwd=str(cwd) if cwd is not None else None, + check=False, + capture_output=True, + text=True, + ) + + +def _detect_git_root(start_dir: Path) -> Path | None: + result = _run_git(["git", "rev-parse", "--show-toplevel"], cwd=start_dir) + if result.returncode != 0: + return None + stdout = result.stdout.strip() + if stdout == "": + return None + return Path(stdout).expanduser().resolve() + + +def _resolve_project_path(project_path: str | Path, *, cwd: Path) -> Path: + resolved = Path(project_path).expanduser() + if not resolved.is_absolute(): + resolved = (cwd / resolved).resolve() + else: + resolved = resolved.resolve() + if not resolved.exists(): + raise ValueError(f"project_path {resolved} does not exist") + if not resolved.is_dir(): + raise ValueError(f"project_path {resolved} must be a directory") + git_root = _detect_git_root(resolved) + if git_root is not None: + return git_root + return resolved + + +def resolve_project_startup_source( + *, + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, + cwd: Path | None = None, +) -> ProjectStartupSource | None: + working_dir = Path.cwd() if cwd is None else cwd.resolve() + if no_project_source: + if project_path is not None or repo_url is not None or repo_ref is not None: + raise ValueError( + "--no-project-source cannot be combined with --project-path, " + "--repo-url, or --repo-ref" + ) + return None + if project_path is not None and repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if repo_ref is not None and repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + if project_path is not None: + resolved_path = _resolve_project_path(project_path, cwd=working_dir) + return ProjectStartupSource( + kind="project_path", + origin_ref=str(resolved_path), + resolved_path=resolved_path, + ) + if repo_url is not None: + normalized_repo_url = repo_url.strip() + if normalized_repo_url == "": + raise ValueError("--repo-url must not be empty") + normalized_repo_ref = None if repo_ref is None else repo_ref.strip() + if normalized_repo_ref == "": + raise ValueError("--repo-ref must not be empty") + return ProjectStartupSource( + kind="repo_url", + origin_ref=normalized_repo_url, + repo_ref=normalized_repo_ref, + ) + detected_root = _detect_git_root(working_dir) + if detected_root is None: + return None + return ProjectStartupSource( + kind="project_path", + origin_ref=str(detected_root), + resolved_path=detected_root, + ) + + +@contextmanager +def materialize_project_startup_source(source: ProjectStartupSource) -> Iterator[Path]: + if source.kind == "project_path": + if source.resolved_path is None: + raise RuntimeError("project_path source is missing a resolved path") + yield source.resolved_path + return + + temp_dir = Path(tempfile.mkdtemp(prefix="pyro-project-source-")) + clone_dir = temp_dir / "clone" + try: + clone_result = _run_git(["git", "clone", "--quiet", source.origin_ref, str(clone_dir)]) + if clone_result.returncode != 0: + stderr = clone_result.stderr.strip() or "git clone failed" + raise RuntimeError(f"failed to clone repo_url {source.origin_ref!r}: {stderr}") + if source.repo_ref is not None: + checkout_result = _run_git( + ["git", "checkout", "--quiet", source.repo_ref], + cwd=clone_dir, + ) + if checkout_result.returncode != 0: + stderr = checkout_result.stderr.strip() or "git checkout failed" + raise RuntimeError( + f"failed to checkout repo_ref {source.repo_ref!r} for " + f"repo_url {source.origin_ref!r}: {stderr}" + ) + yield clone_dir + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +def describe_project_startup_source(source: ProjectStartupSource | None) -> str | None: + if source is None: + return None + if source.kind == "project_path": + return f"the current project at {source.origin_ref}" + if source.repo_ref is None: + return f"the clean clone source {source.origin_ref}" + return f"the clean clone source {source.origin_ref} at ref {source.repo_ref}" diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index daf1820..7206ba9 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pathlib import Path + from mcp.server.fastmcp import FastMCP from pyro_mcp.api import McpToolProfile, Pyro @@ -12,14 +14,26 @@ def create_server( manager: VmManager | None = None, *, profile: McpToolProfile = "workspace-core", + project_path: str | Path | None = None, + repo_url: str | None = None, + repo_ref: str | None = None, + no_project_source: bool = False, ) -> FastMCP: """Create and return a configured MCP server instance. `workspace-core` is the default stable chat-host profile in 4.x. Use `profile="workspace-full"` only when the host truly needs the full - advanced workspace surface. + advanced workspace surface. By default, the server auto-detects the + nearest Git worktree root from its current working directory for + project-aware `workspace_create` calls. """ - return Pyro(manager=manager).create_server(profile=profile) + return Pyro(manager=manager).create_server( + profile=profile, + project_path=project_path, + repo_url=repo_url, + repo_ref=repo_ref, + no_project_source=no_project_source, + ) def main() -> None: diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 6419792..1ba41bb 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.0.0" +DEFAULT_CATALOG_VERSION = "4.1.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.0.0,<5.0.0" + compatibility: str = ">=4.1.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index e25be5f..4384d97 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -116,6 +116,7 @@ WORKSPACE_SECRET_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,63}$") WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] +WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"] WorkspaceSnapshotKind = Literal["baseline", "named"] @@ -524,6 +525,8 @@ class PreparedWorkspaceSeed: mode: WorkspaceSeedMode source_path: str | None + origin_kind: WorkspaceSeedOriginKind = "empty" + origin_ref: str | None = None archive_path: Path | None = None entry_count: int = 0 bytes_written: int = 0 @@ -534,14 +537,19 @@ class PreparedWorkspaceSeed: *, destination: str = WORKSPACE_GUEST_PATH, path_key: str = "seed_path", + include_origin: bool = True, ) -> dict[str, Any]: - return { + payload = { "mode": self.mode, path_key: self.source_path, "destination": destination, "entry_count": self.entry_count, "bytes_written": self.bytes_written, } + if include_origin: + payload["origin_kind"] = self.origin_kind + payload["origin_ref"] = self.origin_ref + return payload def cleanup(self) -> None: if self.cleanup_dir is not None: @@ -614,6 +622,8 @@ def _empty_workspace_seed_payload() -> dict[str, Any]: return { "mode": "empty", "seed_path": None, + "origin_kind": "empty", + "origin_ref": None, "destination": WORKSPACE_GUEST_PATH, "entry_count": 0, "bytes_written": 0, @@ -628,6 +638,8 @@ def _workspace_seed_dict(value: object) -> dict[str, Any]: { "mode": str(value.get("mode", payload["mode"])), "seed_path": _optional_str(value.get("seed_path")), + "origin_kind": str(value.get("origin_kind", payload["origin_kind"])), + "origin_ref": _optional_str(value.get("origin_ref")), "destination": str(value.get("destination", payload["destination"])), "entry_count": int(value.get("entry_count", payload["entry_count"])), "bytes_written": int(value.get("bytes_written", payload["bytes_written"])), @@ -3747,13 +3759,16 @@ class VmManager: secrets: list[dict[str, str]] | None = None, name: str | None = None, labels: dict[str, str] | None = None, + _prepared_seed: PreparedWorkspaceSeed | None = None, ) -> dict[str, Any]: self._validate_limits(vcpu_count=vcpu_count, mem_mib=mem_mib, ttl_seconds=ttl_seconds) get_environment(environment, runtime_paths=self._runtime_paths) normalized_network_policy = _normalize_workspace_network_policy(str(network_policy)) normalized_name = None if name is None else _normalize_workspace_name(name) normalized_labels = _normalize_workspace_labels(labels) - prepared_seed = self._prepare_workspace_seed(seed_path) + if _prepared_seed is not None and seed_path is not None: + raise ValueError("_prepared_seed and seed_path are mutually exclusive") + prepared_seed = _prepared_seed or self._prepare_workspace_seed(seed_path) now = time.time() workspace_id = uuid.uuid4().hex[:12] workspace_dir = self._workspace_dir(workspace_id) @@ -3885,6 +3900,7 @@ class VmManager: workspace_sync = prepared_seed.to_payload( destination=normalized_destination, path_key="source_path", + include_origin=False, ) workspace_sync["entry_count"] = int(import_summary["entry_count"]) workspace_sync["bytes_written"] = int(import_summary["bytes_written"]) @@ -5663,12 +5679,30 @@ class VmManager: execution_mode = instance.metadata.get("execution_mode", "unknown") return exec_result, execution_mode - def _prepare_workspace_seed(self, seed_path: str | Path | None) -> PreparedWorkspaceSeed: + def _prepare_workspace_seed( + self, + seed_path: str | Path | None, + *, + origin_kind: WorkspaceSeedOriginKind | None = None, + origin_ref: str | None = None, + ) -> PreparedWorkspaceSeed: if seed_path is None: - return PreparedWorkspaceSeed(mode="empty", source_path=None) + return PreparedWorkspaceSeed( + mode="empty", + source_path=None, + origin_kind="empty" if origin_kind is None else origin_kind, + origin_ref=origin_ref, + ) resolved_source_path = Path(seed_path).expanduser().resolve() if not resolved_source_path.exists(): raise ValueError(f"seed_path {resolved_source_path} does not exist") + effective_origin_kind: WorkspaceSeedOriginKind = ( + "manual_seed_path" if origin_kind is None else origin_kind + ) + effective_origin_ref = str(resolved_source_path) if origin_ref is None else origin_ref + public_source_path = ( + None if effective_origin_kind == "repo_url" else str(resolved_source_path) + ) if resolved_source_path.is_dir(): cleanup_dir = Path(tempfile.mkdtemp(prefix="pyro-workspace-seed-")) archive_path = cleanup_dir / "workspace-seed.tar" @@ -5680,7 +5714,9 @@ class VmManager: raise return PreparedWorkspaceSeed( mode="directory", - source_path=str(resolved_source_path), + source_path=public_source_path, + origin_kind=effective_origin_kind, + origin_ref=effective_origin_ref, archive_path=archive_path, entry_count=entry_count, bytes_written=bytes_written, @@ -5696,7 +5732,9 @@ class VmManager: entry_count, bytes_written = _inspect_seed_archive(resolved_source_path) return PreparedWorkspaceSeed( mode="tar_archive", - source_path=str(resolved_source_path), + source_path=public_source_path, + origin_kind=effective_origin_kind, + origin_ref=effective_origin_ref, archive_path=resolved_source_path, entry_count=entry_count, bytes_written=bytes_written, diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 01c1338..40e0ed9 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import asyncio import tempfile import time from dataclasses import dataclass @@ -107,6 +108,15 @@ def _log(message: str) -> None: print(f"[smoke] {message}", flush=True) +def _extract_structured_tool_result(raw_result: object) -> dict[str, object]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected MCP tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return structured + + def _create_workspace( pyro: Pyro, *, @@ -126,6 +136,30 @@ def _create_workspace( return str(created["workspace_id"]) +def _create_project_aware_workspace( + pyro: Pyro, + *, + environment: str, + project_path: Path, + name: str, + labels: dict[str, str], +) -> dict[str, object]: + async def _run() -> dict[str, object]: + server = pyro.create_server(profile="workspace-core", project_path=project_path) + return _extract_structured_tool_result( + await server.call_tool( + "workspace_create", + { + "environment": environment, + "name": name, + "labels": labels, + }, + ) + ) + + return asyncio.run(_run()) + + def _safe_delete_workspace(pyro: Pyro, workspace_id: str | None) -> None: if workspace_id is None: return @@ -221,14 +255,19 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non ) workspace_id: str | None = None try: - workspace_id = _create_workspace( + created = _create_project_aware_workspace( pyro, environment=environment, - seed_path=seed_dir, + project_path=seed_dir, name="repro-fix-loop", labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"}, ) + workspace_id = str(created["workspace_id"]) _log(f"repro-fix-loop workspace_id={workspace_id}") + workspace_seed = created["workspace_seed"] + assert isinstance(workspace_seed, dict), created + assert workspace_seed["origin_kind"] == "project_path", created + assert workspace_seed["origin_ref"] == str(seed_dir.resolve()), created initial_read = pyro.read_workspace_file(workspace_id, "message.txt") assert str(initial_read["content"]) == "broken\n", initial_read failing = pyro.exec_workspace(workspace_id, command="sh check.sh") diff --git a/tests/test_api.py b/tests/test_api.py index 8772754..29bda64 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import subprocess import time from pathlib import Path from typing import Any, cast @@ -15,6 +16,28 @@ from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_network import TapNetworkManager +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / "note.txt").write_text(content, encoding="utf-8") + _git(root, "add", "note.txt") + _git(root, "commit", "-m", "init") + return root + + def test_pyro_run_in_vm_delegates_to_manager(tmp_path: Path) -> None: pyro = Pyro( manager=VmManager( @@ -134,6 +157,58 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_ assert "workspace_disk_export" not in tool_map +def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed( + tmp_path: Path, +) -> None: + repo = _make_repo(tmp_path / "repo", content="project-aware\n") + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + def _extract_structured(raw_result: object) -> dict[str, Any]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected call_tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return cast(dict[str, Any], structured) + + async def _run() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + server = pyro.create_server(project_path=repo) + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": created["workspace_id"], + "command": "cat note.txt", + }, + ) + ) + return tool_map["workspace_create"], created, executed + + workspace_create_tool, created, executed = asyncio.run(_run()) + assert "If `seed_path` is omitted" in str(workspace_create_tool["description"]) + assert str(repo.resolve()) in str(workspace_create_tool["description"]) + assert created["workspace_seed"]["origin_kind"] == "project_path" + assert created["workspace_seed"]["origin_ref"] == str(repo.resolve()) + assert executed["stdout"] == "project-aware\n" + + def test_pyro_vm_run_tool_executes(tmp_path: Path) -> None: pyro = Pyro( manager=VmManager( diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b276ab..4cd085c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -73,6 +73,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "recommended first profile for most chat hosts" in mcp_help assert "workspace-core: default for normal persistent chat editing" in mcp_help assert "workspace-full: larger opt-in surface" in mcp_help + assert "--project-path" in mcp_help + assert "--repo-url" in mcp_help + assert "--repo-ref" in mcp_help + assert "--no-project-source" in mcp_help + assert "pyro mcp serve --project-path ." in mcp_help + assert "pyro mcp serve --repo-url https://github.com/example/project.git" in mcp_help workspace_help = _subparser_choice(parser, "workspace").format_help() assert "Use the workspace model when you need one sandbox to stay alive" in workspace_help @@ -2825,25 +2831,30 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert claude_cmd in readme assert codex_cmd in readme assert "examples/opencode_mcp_config.json" 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 "--project-path /abs/path/to/repo" in readme + assert "--repo-url https://github.com/example/project.git" in readme assert "## 5. Connect a chat host" in install assert "uvx --from pyro-mcp pyro mcp serve" in install assert claude_cmd in install assert codex_cmd in install assert "workspace-full" in install + assert "--project-path /abs/path/to/repo" in install assert claude_cmd in first_run assert codex_cmd in first_run + assert "--project-path /abs/path/to/repo" in first_run - assert ( - "Bare `pyro mcp serve` starts `workspace-core`. That is the product path." - in integrations - ) + assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations + assert "auto-detects the current Git checkout" in integrations assert "examples/claude_code_mcp.md" in integrations assert "examples/codex_mcp.md" in integrations assert "examples/opencode_mcp_config.json" in integrations assert "That is the product path." in integrations + assert "--project-path /abs/path/to/repo" in integrations + assert "--repo-url https://github.com/example/project.git" in integrations assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config assert "Use the host-specific examples first when they apply:" in mcp_config @@ -2854,10 +2865,12 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert claude_cmd in claude_code assert "claude mcp list" in claude_code assert "workspace-full" in claude_code + assert "--project-path /abs/path/to/repo" in claude_code assert codex_cmd in codex assert "codex mcp list" in codex assert "workspace-full" in codex + assert "--project-path /abs/path/to/repo" in codex assert opencode == { "mcp": { @@ -4020,11 +4033,23 @@ def test_cli_run_json_error_exits_nonzero( def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: - observed: dict[str, str] = {} + observed: dict[str, Any] = {} class StubPyro: - def create_server(self, *, profile: str) -> Any: + def create_server( + self, + *, + profile: str, + project_path: str | None, + repo_url: str | None, + repo_ref: str | None, + no_project_source: bool, + ) -> Any: observed["profile"] = profile + observed["project_path"] = project_path + observed["repo_url"] = repo_url + observed["repo_ref"] = repo_ref + observed["no_project_source"] = no_project_source return type( "StubServer", (), @@ -4033,12 +4058,27 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="mcp", mcp_command="serve", profile="workspace-core") + return argparse.Namespace( + command="mcp", + mcp_command="serve", + profile="workspace-core", + project_path="/repo", + repo_url=None, + repo_ref=None, + no_project_source=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() - assert observed == {"profile": "workspace-core", "transport": "stdio"} + assert observed == { + "profile": "workspace-core", + "project_path": "/repo", + "repo_url": None, + "repo_ref": None, + "no_project_source": False, + "transport": "stdio", + } def test_cli_demo_default_prints_json( diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py new file mode 100644 index 0000000..667c05f --- /dev/null +++ b/tests/test_project_startup.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from pyro_mcp.project_startup import ( + ProjectStartupSource, + describe_project_startup_source, + materialize_project_startup_source, + resolve_project_startup_source, +) + + +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, filename: str = "note.txt", content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / filename).write_text(content, encoding="utf-8") + _git(root, "add", filename) + _git(root, "commit", "-m", "init") + return root + + +def test_resolve_project_startup_source_detects_nearest_git_root(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + nested = repo / "src" / "pkg" + nested.mkdir(parents=True) + + resolved = resolve_project_startup_source(cwd=nested) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + + +def test_resolve_project_startup_source_project_path_prefers_git_root(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + nested = repo / "nested" + nested.mkdir() + + resolved = resolve_project_startup_source(project_path=nested, cwd=tmp_path) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + + +def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + + with pytest.raises(ValueError, match="mutually exclusive"): + resolve_project_startup_source(project_path=repo, repo_url="https://example.com/repo.git") + + with pytest.raises(ValueError, match="requires --repo-url"): + resolve_project_startup_source(repo_ref="main") + + with pytest.raises(ValueError, match="cannot be combined"): + resolve_project_startup_source(project_path=repo, no_project_source=True) + + +def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo", content="one\n") + first_commit = _git(repo, "rev-parse", "HEAD") + (repo / "note.txt").write_text("two\n", encoding="utf-8") + _git(repo, "add", "note.txt") + _git(repo, "commit", "-m", "update") + + source = ProjectStartupSource( + kind="repo_url", + origin_ref=str(repo.resolve()), + repo_ref=first_commit, + ) + + with materialize_project_startup_source(source) as clone_dir: + assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n" + + +def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None: + repo = _make_repo(tmp_path / "repo") + + project_description = describe_project_startup_source( + ProjectStartupSource( + kind="project_path", + origin_ref=str(repo.resolve()), + resolved_path=repo.resolve(), + ) + ) + repo_description = describe_project_startup_source( + ProjectStartupSource( + kind="repo_url", + origin_ref="https://example.com/repo.git", + repo_ref="main", + ) + ) + + assert project_description == f"the current project at {repo.resolve()}" + assert repo_description == "the clean clone source https://example.com/repo.git at ref main" diff --git a/tests/test_server.py b/tests/test_server.py index 1481149..f36c1ff 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import subprocess from pathlib import Path from typing import Any, cast @@ -16,6 +17,28 @@ from pyro_mcp.vm_manager import VmManager from pyro_mcp.vm_network import TapNetworkManager +def _git(repo: Path, *args: str) -> str: + result = subprocess.run( # noqa: S603 + ["git", *args], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _make_repo(root: Path, *, content: str = "hello\n") -> Path: + root.mkdir() + _git(root, "init") + _git(root, "config", "user.name", "Pyro Tests") + _git(root, "config", "user.email", "pyro-tests@example.com") + (root / "note.txt").write_text(content, encoding="utf-8") + _git(root, "add", "note.txt") + _git(root, "commit", "-m", "init") + return root + + def test_create_server_registers_vm_tools(tmp_path: Path) -> None: manager = VmManager( backend_name="mock", @@ -62,6 +85,121 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path: assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) +def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + repo = _make_repo(tmp_path / "repo") + + async def _run() -> dict[str, Any]: + server = create_server(manager=manager, project_path=repo) + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return tool_map["workspace_create"] + + workspace_create = asyncio.run(_run()) + description = cast(str, workspace_create["description"]) + assert "If `seed_path` is omitted" in description + assert str(repo.resolve()) in description + + +def test_create_server_project_path_seeds_workspace_when_seed_path_is_omitted( + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + repo = _make_repo(tmp_path / "repo", content="project-aware\n") + + def _extract_structured(raw_result: object) -> dict[str, Any]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected call_tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return cast(dict[str, Any], structured) + + async def _run() -> tuple[dict[str, Any], dict[str, Any]]: + server = create_server(manager=manager, project_path=repo) + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": created["workspace_id"], + "command": "cat note.txt", + }, + ) + ) + return created, executed + + created, executed = asyncio.run(_run()) + assert created["workspace_seed"]["mode"] == "directory" + assert created["workspace_seed"]["seed_path"] == str(repo.resolve()) + assert created["workspace_seed"]["origin_kind"] == "project_path" + assert created["workspace_seed"]["origin_ref"] == str(repo.resolve()) + assert executed["stdout"] == "project-aware\n" + + +def test_create_server_repo_url_seeds_workspace_when_seed_path_is_omitted(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + repo = _make_repo(tmp_path / "repo", content="committed\n") + (repo / "note.txt").write_text("dirty\n", encoding="utf-8") + + def _extract_structured(raw_result: object) -> dict[str, Any]: + if not isinstance(raw_result, tuple) or len(raw_result) != 2: + raise TypeError("unexpected call_tool result shape") + _, structured = raw_result + if not isinstance(structured, dict): + raise TypeError("expected structured dictionary result") + return cast(dict[str, Any], structured) + + async def _run() -> tuple[dict[str, Any], dict[str, Any]]: + server = create_server(manager=manager, repo_url=str(repo.resolve())) + created = _extract_structured( + await server.call_tool( + "workspace_create", + { + "environment": "debian:12-base", + "allow_host_compat": True, + }, + ) + ) + executed = _extract_structured( + await server.call_tool( + "workspace_exec", + { + "workspace_id": created["workspace_id"], + "command": "cat note.txt", + }, + ) + ) + return created, executed + + created, executed = asyncio.run(_run()) + assert created["workspace_seed"]["mode"] == "directory" + assert created["workspace_seed"]["seed_path"] is None + assert created["workspace_seed"]["origin_kind"] == "repo_url" + assert created["workspace_seed"]["origin_ref"] == str(repo.resolve()) + assert executed["stdout"] == "committed\n" + + def test_vm_run_round_trip(tmp_path: Path) -> None: manager = VmManager( backend_name="mock", diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index 1a5a836..b02fd10 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -436,6 +436,36 @@ class _FakePyro: workspace.shells.pop(shell_id, None) return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True} + def create_server(self, *, profile: str, project_path: Path) -> Any: + assert profile == "workspace-core" + seed_path = Path(project_path) + outer = self + + class _FakeServer: + async def call_tool( + self, + tool_name: str, + arguments: dict[str, Any], + ) -> tuple[None, dict[str, Any]]: + if tool_name != "workspace_create": + raise AssertionError(f"unexpected tool call: {tool_name}") + result = outer.create_workspace( + environment=cast(str, arguments["environment"]), + seed_path=seed_path, + name=cast(str | None, arguments.get("name")), + labels=cast(dict[str, str] | None, arguments.get("labels")), + ) + created = outer.status_workspace(cast(str, result["workspace_id"])) + created["workspace_seed"] = { + "mode": "directory", + "seed_path": str(seed_path.resolve()), + "origin_kind": "project_path", + "origin_ref": str(seed_path.resolve()), + } + return None, created + + return _FakeServer() + def test_use_case_registry_has_expected_scenarios() -> None: expected = ( diff --git a/uv.lock b/uv.lock index 95bd68b..eb006fe 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.0.0" +version = "4.1.0" source = { editable = "." } dependencies = [ { name = "mcp" }, From 899a6760c45d2eaac989889dbcbd673291803007 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 16:46:10 -0300 Subject: [PATCH 4/8] Add host bootstrap and repair helpers Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits. Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material. Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here. Validation: - uv lock - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check --- CHANGELOG.md | 12 + Makefile | 5 +- README.md | 38 +- docs/first-run.md | 21 +- docs/install.md | 29 +- docs/integrations.md | 46 ++ docs/public-contract.md | 11 + docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.2.0-host-bootstrap-and-repair.md | 2 +- examples/claude_code_mcp.md | 14 + examples/codex_mcp.md | 14 + examples/mcp_client_config.md | 8 + pyproject.toml | 2 +- src/pyro_mcp/cli.py | 284 +++++++++- src/pyro_mcp/contract.py | 15 +- src/pyro_mcp/host_helpers.py | 363 +++++++++++++ src/pyro_mcp/vm_environments.py | 4 +- tests/test_api.py | 2 +- tests/test_cli.py | 151 +++++- tests/test_host_helpers.py | 484 ++++++++++++++++++ tests/test_project_startup.py | 123 ++++- tests/test_public_contract.py | 28 + tests/test_server.py | 2 +- tests/test_workspace_ports.py | 48 +- uv.lock | 2 +- 25 files changed, 1658 insertions(+), 58 deletions(-) create mode 100644 src/pyro_mcp/host_helpers.py create mode 100644 tests/test_host_helpers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 70987e6..d30ab17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.2.0 + +- Added host bootstrap and repair helpers with `pyro host connect`, + `pyro host print-config`, `pyro host doctor`, and `pyro host repair` for the + supported Claude Code, Codex, and OpenCode flows. +- Repositioned the docs and examples so supported hosts now start from the + helper flow first, while keeping raw `pyro mcp serve` commands as the + underlying MCP entrypoint and advanced fallback. +- Added deterministic host-helper coverage so the shipped helper commands and + OpenCode config snippet stay aligned with the canonical `pyro mcp serve` + command shape. + ## 4.1.0 - Added project-aware MCP startup so bare `pyro mcp serve` from a repo root can diff --git a/Makefile b/Makefile index 1211c8f..46ebd93 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ PYTHON ?= uv run python UV_CACHE_DIR ?= .uv-cache -PYTEST_FLAGS ?= -n auto +PYTEST_WORKERS ?= $(shell sh -c 'n=$$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc 2>/dev/null || echo 2); if [ "$$n" -gt 8 ]; then n=8; fi; if [ "$$n" -lt 2 ]; then echo 1; else echo $$n; fi') +PYTEST_FLAGS ?= -n $(PYTEST_WORKERS) OLLAMA_BASE_URL ?= http://localhost:11434/v1 OLLAMA_MODEL ?= llama3.2:3b OLLAMA_DEMO_FLAGS ?= @@ -84,6 +85,8 @@ check: lint typecheck test dist-check: uv run pyro --version uv run pyro --help >/dev/null + uv run pyro host --help >/dev/null + uv run pyro host doctor >/dev/null uv run pyro mcp --help >/dev/null uv run pyro run --help >/dev/null uv run pyro env list >/dev/null diff --git a/README.md b/README.md index 848fc29..8ab2054 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.1.0: [CHANGELOG.md#410](CHANGELOG.md#410) +- What's new in 4.2.0: [CHANGELOG.md#420](CHANGELOG.md#420) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -76,7 +76,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.1.0 +Catalog version: 4.2.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -96,9 +96,26 @@ for the guest image. ## Chat Host Quickstart After the quickstart works, the intended next step is to connect a chat host. -From a repo root, bare `pyro mcp serve` starts `workspace-core`, auto-detects -the current Git checkout, and lets the first `workspace_create` omit -`seed_path`. +Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect claude-code +uvx --from pyro-mcp pyro host connect codex +uvx --from pyro-mcp pyro host print-config opencode +``` + +If setup drifts or you want to inspect it first: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + +Those helpers wrap the same `pyro mcp serve` entrypoint. From a repo root, +bare `pyro mcp serve` starts `workspace-core`, auto-detects the current Git +checkout, and lets the first `workspace_create` omit `seed_path`. ```bash uvx --from pyro-mcp pyro mcp serve @@ -107,12 +124,14 @@ uvx --from pyro-mcp pyro mcp serve If the host does not preserve the server working directory, use: ```bash +uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo ``` If you are starting outside a local checkout, use a clean clone source: ```bash +uvx --from pyro-mcp pyro host connect codex --repo-url https://github.com/example/project.git uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/project.git ``` @@ -149,7 +168,8 @@ OpenCode `opencode.json` snippet: } ``` -If OpenCode launches the server from an unexpected cwd, add +If OpenCode launches the server from an unexpected cwd, use +`pyro host print-config opencode --project-path /abs/path/to/repo` or add `"--project-path", "/abs/path/to/repo"` after `"serve"` in the same command array. @@ -163,9 +183,9 @@ snapshots, secrets, network policy, or disk tools. 1. Validate the host with `pyro doctor`. 2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. -3. Connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo - root, or use `--project-path` / `--repo-url` when cwd is not the source of - truth. +3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or + `pyro host print-config opencode`, then fall back to raw `pyro mcp serve` + with `--project-path` / `--repo-url` when cwd is not the source of truth. 4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). `repro-fix-loop` is the shortest chat-first story. 5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path diff --git a/docs/first-run.md b/docs/first-run.md index 6b40344..6fa3442 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.1.0 +Catalog version: 4.2.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -93,9 +93,27 @@ $ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/proje ## 6. Connect a chat host +Use the helper flow first: + +```bash +$ uvx --from pyro-mcp pyro host connect claude-code +$ uvx --from pyro-mcp pyro host connect codex +$ uvx --from pyro-mcp pyro host print-config opencode +``` + +If setup drifts later: + +```bash +$ uvx --from pyro-mcp pyro host doctor +$ uvx --from pyro-mcp pyro host repair claude-code +$ uvx --from pyro-mcp pyro host repair codex +$ uvx --from pyro-mcp pyro host repair opencode +``` + Claude Code: ```bash +$ uvx --from pyro-mcp pyro host connect claude-code $ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve $ claude mcp list ``` @@ -103,6 +121,7 @@ $ claude mcp list Codex: ```bash +$ uvx --from pyro-mcp pyro host connect codex $ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve $ codex mcp list ``` diff --git a/docs/install.md b/docs/install.md index cb02312..1879146 100644 --- a/docs/install.md +++ b/docs/install.md @@ -62,7 +62,8 @@ pyro run debian:12 -- git --version If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. -After that one-shot proof works, the intended next step is `pyro mcp serve`. +After that one-shot proof works, the intended next step is `pyro host connect` +or `pyro host print-config`. ## 1. Check the host @@ -92,7 +93,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.1.0 +Catalog version: 4.2.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -140,6 +141,23 @@ deterministic structured result. ## 5. Connect a chat host +Use the helper flow first: + +```bash +uvx --from pyro-mcp pyro host connect claude-code +uvx --from pyro-mcp pyro host connect codex +uvx --from pyro-mcp pyro host print-config opencode +``` + +If setup drifts later, inspect and repair it with: + +```bash +uvx --from pyro-mcp pyro host doctor +uvx --from pyro-mcp pyro host repair claude-code +uvx --from pyro-mcp pyro host repair codex +uvx --from pyro-mcp pyro host repair opencode +``` + Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also auto-detects the current Git checkout so the first `workspace_create` can omit `seed_path`. @@ -170,12 +188,14 @@ Copy-paste host-specific starts: Claude Code: ```bash +pyro host connect claude-code claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` Codex: ```bash +pyro host connect codex codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve ``` @@ -195,8 +215,9 @@ The intended user journey is: 1. validate the host with `pyro doctor` 2. pull `debian:12` 3. prove guest execution with `pyro run debian:12 -- git --version` -4. connect Claude Code, Codex, or OpenCode with `pyro mcp serve` from a repo - root, or use `--project-path` / `--repo-url` when needed +4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or + `pyro host print-config opencode`, then use raw `pyro mcp serve` only when + you need `--project-path` / `--repo-url` 5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) 6. trust but verify with `make smoke-use-cases` diff --git a/docs/integrations.md b/docs/integrations.md index 7c0a96b..933a5ea 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -38,8 +38,35 @@ pyro mcp serve --repo-url https://github.com/example/project.git Use `--profile workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools. +## Helper First + +Use the helper flow before the raw host CLI commands: + +```bash +pyro host connect claude-code +pyro host connect codex +pyro host print-config opencode +pyro host doctor +pyro host repair opencode +``` + +These helpers wrap the same `pyro mcp serve` entrypoint, preserve the current +`workspace-core` default, and make it obvious how to repair drift later. + ## Claude Code +Preferred: + +```bash +pyro host connect claude-code +``` + +Repair: + +```bash +pyro host repair claude-code +``` + Package without install: ```bash @@ -66,6 +93,18 @@ Reference: ## Codex +Preferred: + +```bash +pyro host connect codex +``` + +Repair: + +```bash +pyro host repair codex +``` + Package without install: ```bash @@ -92,6 +131,13 @@ Reference: ## OpenCode +Preferred: + +```bash +pyro host print-config opencode +pyro host repair opencode +``` + Use the local MCP config shape from: - [opencode_mcp_config.json](../examples/opencode_mcp_config.json) diff --git a/docs/public-contract.md b/docs/public-contract.md index ae5c55e..b061cd5 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -94,6 +94,17 @@ Host-specific setup docs: - [opencode_mcp_config.json](../examples/opencode_mcp_config.json) - [mcp_client_config.md](../examples/mcp_client_config.md) +The chat-host bootstrap helper surface is: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair HOST` + +These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred +setup and repair path for supported hosts. + ## Chat-Facing Workspace Contract `workspace-core` is the normal chat path. It exposes: diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 6a40a81..ff6ccc6 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.1.0`: +Current baseline is `4.2.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -80,7 +80,7 @@ capability gaps: 10. [`3.11.0` Host-Specific MCP Onramps](llm-chat-ergonomics/3.11.0-host-specific-mcp-onramps.md) - Done 11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done 12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done -13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Planned +13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - Done 14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Planned 15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Planned 16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned @@ -117,10 +117,12 @@ Completed so far: - `4.1.0` made repo-root startup native for chat hosts, so bare `pyro mcp serve` can auto-detect the current Git checkout and let the first `workspace_create` omit `seed_path`, with explicit `--project-path` and `--repo-url` fallbacks when cwd is not the source of truth. +- `4.2.0` adds first-class host bootstrap and repair helpers so Claude Code, + Codex, and OpenCode users can connect or repair the supported chat-host path + without manually composing raw MCP commands or config edits. Planned next: -- [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) diff --git a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md index 4ccf34b..a9d07a4 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md +++ b/docs/roadmap/llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md @@ -1,6 +1,6 @@ # `4.2.0` Host Bootstrap And Repair -Status: Planned +Status: Done ## Goal diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index 3dbc5d0..0beb63c 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -2,6 +2,13 @@ Recommended profile: `workspace-core`. +Preferred helper flow: + +```bash +pyro host connect claude-code +pyro host doctor +``` + Package without install: ```bash @@ -23,9 +30,16 @@ If Claude Code launches the server from an unexpected cwd, pin the project explicitly: ```bash +pyro host connect claude-code --project-path /abs/path/to/repo claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo ``` +If the local config drifts later: + +```bash +pyro host repair claude-code +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index aa38813..330f38e 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -2,6 +2,13 @@ Recommended profile: `workspace-core`. +Preferred helper flow: + +```bash +pyro host connect codex +pyro host doctor +``` + Package without install: ```bash @@ -23,9 +30,16 @@ If Codex launches the server from an unexpected cwd, pin the project explicitly: ```bash +pyro host connect codex --project-path /abs/path/to/repo codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo ``` +If the local config drifts later: + +```bash +pyro host repair codex +``` + Move to `workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools: diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index 10ee175..d0b2046 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -8,6 +8,14 @@ Use the host-specific examples first when they apply: - Codex: [examples/codex_mcp.md](codex_mcp.md) - OpenCode: [examples/opencode_mcp_config.json](opencode_mcp_config.json) +Preferred repair/bootstrap helpers: + +- `pyro host connect claude-code` +- `pyro host connect codex` +- `pyro host print-config opencode` +- `pyro host doctor` +- `pyro host repair opencode` + Use this generic config only when the host expects a plain `mcpServers` JSON shape. diff --git a/pyproject.toml b/pyproject.toml index 7bf1bc9..1d6b8e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.1.0" +version = "4.2.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 4011d18..204df2a 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -8,12 +8,20 @@ import shlex import sys from pathlib import Path from textwrap import dedent -from typing import Any +from typing import Any, cast from pyro_mcp import __version__ -from pyro_mcp.api import Pyro +from pyro_mcp.api import McpToolProfile, Pyro from pyro_mcp.contract import PUBLIC_MCP_PROFILES from pyro_mcp.demo import run_demo +from pyro_mcp.host_helpers import ( + HostDoctorEntry, + HostServerConfig, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION @@ -169,6 +177,62 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: print(f"- {issue}") +def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig: + return HostServerConfig( + installed_package=bool(getattr(args, "installed_package", False)), + profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))), + project_path=getattr(args, "project_path", None), + repo_url=getattr(args, "repo_url", None), + repo_ref=getattr(args, "repo_ref", None), + no_project_source=bool(getattr(args, "no_project_source", False)), + ) + + +def _print_host_connect_human(payload: dict[str, Any]) -> None: + host = str(payload.get("host", "unknown")) + server_command = payload.get("server_command") + verification_command = payload.get("verification_command") + print(f"Connected pyro to {host}.") + if isinstance(server_command, list): + print("Server command: " + shlex.join(str(item) for item in server_command)) + if isinstance(verification_command, list): + print("Verify with: " + shlex.join(str(item) for item in verification_command)) + + +def _print_host_print_config_human(payload: dict[str, Any]) -> None: + rendered_config = payload.get("rendered_config") + if isinstance(rendered_config, str): + _write_stream(rendered_config, stream=sys.stdout) + return + output_path = payload.get("output_path") + if isinstance(output_path, str): + print(f"Wrote OpenCode config to {output_path}") + + +def _print_host_repair_human(payload: dict[str, Any]) -> None: + host = str(payload.get("host", "unknown")) + if host == "opencode": + print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.") + backup_path = payload.get("backup_path") + if isinstance(backup_path, str): + print(f"Backed up the previous config to {backup_path}.") + return + _print_host_connect_human(payload) + + +def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None: + for index, entry in enumerate(entries): + print( + f"{entry.host}: {entry.status} " + f"installed={'yes' if entry.installed else 'no'} " + f"configured={'yes' if entry.configured else 'no'}" + ) + print(f" details: {entry.details}") + print(f" repair: {entry.repair_command}") + if index != len(entries) - 1: + print() + + def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None: print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}") name = payload.get("name") @@ -645,6 +709,38 @@ class _HelpFormatter( return help_string +def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--installed-package", + action="store_true", + help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.", + ) + parser.add_argument( + "--profile", + choices=PUBLIC_MCP_PROFILES, + default="workspace-core", + help="Server profile to configure for the host helper flow.", + ) + source_group = parser.add_mutually_exclusive_group() + source_group.add_argument( + "--project-path", + help="Pin the server to this local project path instead of relying on host cwd.", + ) + source_group.add_argument( + "--repo-url", + help="Seed default workspaces from a clean clone of this repository URL.", + ) + source_group.add_argument( + "--no-project-source", + action="store_true", + help="Disable automatic Git checkout detection from the current working directory.", + ) + parser.add_argument( + "--repo-ref", + help="Optional branch, tag, or commit to checkout after cloning --repo-url.", + ) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( @@ -658,11 +754,12 @@ def _build_parser() -> argparse.ArgumentParser: pyro env list pyro env pull debian:12 pyro run debian:12 -- git --version - pyro mcp serve + pyro host connect claude-code Connect a chat host after that: - claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve - codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve + pyro host connect claude-code + pyro host connect codex + pyro host print-config opencode If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only @@ -767,6 +864,129 @@ def _build_parser() -> argparse.ArgumentParser: help="Print structured JSON instead of human-readable output.", ) + host_parser = subparsers.add_parser( + "host", + help="Bootstrap and repair supported chat-host configs.", + description=( + "Connect or repair the supported Claude Code, Codex, and OpenCode " + "host setups without hand-writing MCP commands or config." + ), + epilog=dedent( + """ + Examples: + pyro host connect claude-code + pyro host connect codex --project-path /abs/path/to/repo + pyro host print-config opencode + pyro host repair opencode + pyro host doctor + """ + ), + formatter_class=_HelpFormatter, + ) + host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST") + host_connect_parser = host_subparsers.add_parser( + "connect", + help="Connect Claude Code or Codex in one step.", + description=( + "Ensure the supported host has a `pyro` MCP server entry that wraps " + "the canonical `pyro mcp serve` command." + ), + epilog=dedent( + """ + Examples: + pyro host connect claude-code + pyro host connect codex --installed-package + pyro host connect codex --project-path /abs/path/to/repo + """ + ), + formatter_class=_HelpFormatter, + ) + host_connect_parser.add_argument( + "host", + choices=("claude-code", "codex"), + help="Chat host to connect and update in place.", + ) + _add_host_server_source_args(host_connect_parser) + + host_print_config_parser = host_subparsers.add_parser( + "print-config", + help="Print or write the canonical OpenCode config snippet.", + description=( + "Render the canonical OpenCode `mcp.pyro` config entry so it can be " + "copied into or written to `opencode.json`." + ), + epilog=dedent( + """ + Examples: + pyro host print-config opencode + pyro host print-config opencode --output ./opencode.json + pyro host print-config opencode --project-path /abs/path/to/repo + """ + ), + formatter_class=_HelpFormatter, + ) + host_print_config_parser.add_argument( + "host", + choices=("opencode",), + help="Host config shape to render.", + ) + _add_host_server_source_args(host_print_config_parser) + host_print_config_parser.add_argument( + "--output", + help="Write the rendered JSON to this path instead of printing it to stdout.", + ) + + host_doctor_parser = host_subparsers.add_parser( + "doctor", + help="Inspect supported host setup status.", + description=( + "Report whether Claude Code, Codex, and OpenCode are installed, " + "configured, missing, or drifted relative to the canonical `pyro` MCP setup." + ), + epilog=dedent( + """ + Examples: + pyro host doctor + pyro host doctor --project-path /abs/path/to/repo + pyro host doctor --installed-package + """ + ), + formatter_class=_HelpFormatter, + ) + _add_host_server_source_args(host_doctor_parser) + host_doctor_parser.add_argument( + "--config-path", + help="Override the OpenCode config path when inspecting or repairing that host.", + ) + + host_repair_parser = host_subparsers.add_parser( + "repair", + help="Repair one supported host to the canonical `pyro` setup.", + description=( + "Repair a stale or broken host config by reapplying the canonical " + "`pyro mcp serve` setup for that host." + ), + epilog=dedent( + """ + Examples: + pyro host repair claude-code + pyro host repair codex --project-path /abs/path/to/repo + pyro host repair opencode + """ + ), + formatter_class=_HelpFormatter, + ) + host_repair_parser.add_argument( + "host", + choices=("claude-code", "codex", "opencode"), + help="Host config to repair.", + ) + _add_host_server_source_args(host_repair_parser) + host_repair_parser.add_argument( + "--config-path", + help="Override the OpenCode config path when repairing that host.", + ) + mcp_parser = subparsers.add_parser( "mcp", help="Run the MCP server.", @@ -2350,6 +2570,57 @@ def main() -> None: else: _print_prune_human(prune_payload) return + if args.command == "host": + config = _build_host_server_config(args) + if args.host_command == "connect": + try: + payload = connect_cli_host(args.host, config=config) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_connect_human(payload) + return + if args.host_command == "print-config": + try: + output_path = ( + None if args.output is None else Path(args.output).expanduser().resolve() + ) + payload = print_or_write_opencode_config(config=config, output_path=output_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_print_config_human(payload) + return + if args.host_command == "doctor": + try: + config_path = ( + None + if args.config_path is None + else Path(args.config_path).expanduser().resolve() + ) + entries = doctor_hosts(config=config, config_path=config_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_doctor_human(entries) + return + if args.host_command == "repair": + try: + if args.host != "opencode" and args.config_path is not None: + raise ValueError( + "--config-path is only supported for `pyro host repair opencode`" + ) + config_path = ( + None + if args.config_path is None + else Path(args.config_path).expanduser().resolve() + ) + payload = repair_host(args.host, config=config, config_path=config_path) + except Exception as exc: # noqa: BLE001 + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + _print_host_repair_human(payload) + return if args.command == "mcp": pyro.create_server( profile=args.profile, @@ -2497,7 +2768,8 @@ def main() -> None: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_exec_human(payload) - exit_code = int(payload.get("exit_code", 1)) + exit_code_raw = payload.get("exit_code", 1) + exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1 if exit_code != 0: raise SystemExit(exit_code) return diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 68ce11f..f706633 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,9 +2,22 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") +PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") +PUBLIC_CLI_HOST_COMMON_FLAGS = ( + "--installed-package", + "--profile", + "--project-path", + "--repo-url", + "--repo-ref", + "--no-project-source", +) +PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS +PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) +PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",) +PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) PUBLIC_CLI_MCP_SERVE_FLAGS = ( "--profile", diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py new file mode 100644 index 0000000..42420bb --- /dev/null +++ b/src/pyro_mcp/host_helpers.py @@ -0,0 +1,363 @@ +"""Helpers for bootstrapping and repairing supported MCP chat hosts.""" + +from __future__ import annotations + +import json +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Literal + +from pyro_mcp.api import McpToolProfile + +SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex") +SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") +SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",) +DEFAULT_HOST_SERVER_NAME = "pyro" +DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json" + +HostStatus = Literal["drifted", "missing", "ok", "unavailable"] + + +@dataclass(frozen=True) +class HostServerConfig: + installed_package: bool = False + profile: McpToolProfile = "workspace-core" + project_path: str | None = None + repo_url: str | None = None + repo_ref: str | None = None + no_project_source: bool = False + + +@dataclass(frozen=True) +class HostDoctorEntry: + host: str + installed: bool + configured: bool + status: HostStatus + details: str + repair_command: str + + +def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 + command, + check=False, + capture_output=True, + text=True, + ) + + +def _host_binary(host: str) -> str: + if host == "claude-code": + return "claude" + if host == "codex": + return "codex" + raise ValueError(f"unsupported CLI host {host!r}") + + +def _canonical_server_command(config: HostServerConfig) -> list[str]: + if config.project_path is not None and config.repo_url is not None: + raise ValueError("--project-path and --repo-url are mutually exclusive") + if config.no_project_source and ( + config.project_path is not None + or config.repo_url is not None + or config.repo_ref is not None + ): + raise ValueError( + "--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref" + ) + if config.repo_ref is not None and config.repo_url is None: + raise ValueError("--repo-ref requires --repo-url") + + command = ["pyro", "mcp", "serve"] + if not config.installed_package: + command = ["uvx", "--from", "pyro-mcp", *command] + if config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + return command + + +def _render_cli_command(command: list[str]) -> str: + return shlex.join(command) + + +def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str: + command = ["pyro", "host", "repair", host] + if config.installed_package: + command.append("--installed-package") + if config.profile != "workspace-core": + command.extend(["--profile", config.profile]) + if config.project_path is not None: + command.extend(["--project-path", config.project_path]) + elif config.repo_url is not None: + command.extend(["--repo-url", config.repo_url]) + if config.repo_ref is not None: + command.extend(["--repo-ref", config.repo_ref]) + elif config.no_project_source: + command.append("--no-project-source") + if config_path is not None: + command.extend(["--config-path", str(config_path)]) + return _render_cli_command(command) + + +def _command_matches(output: str, expected: list[str]) -> bool: + normalized_output = output.strip() + if ":" in normalized_output: + normalized_output = normalized_output.split(":", 1)[1].strip() + try: + parsed = shlex.split(normalized_output) + except ValueError: + parsed = normalized_output.split() + return parsed == expected + + +def _upsert_opencode_config( + *, + config_path: Path, + config: HostServerConfig, +) -> tuple[dict[str, object], Path | None]: + existing_payload: dict[str, object] = {} + backup_path: Path | None = None + if config_path.exists(): + raw_text = config_path.read_text(encoding="utf-8") + try: + parsed = json.loads(raw_text) + except json.JSONDecodeError: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + parsed = {} + if isinstance(parsed, dict): + existing_payload = parsed + else: + timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S") + backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}") + shutil.move(str(config_path), str(backup_path)) + payload = dict(existing_payload) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict): + mcp_payload = {} + else: + mcp_payload = dict(mcp_payload) + mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config) + payload["mcp"] = mcp_payload + return payload, backup_path + + +def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]: + return { + "type": "local", + "enabled": True, + "command": _canonical_server_command(config), + } + + +def render_opencode_config(config: HostServerConfig) -> str: + return ( + json.dumps( + {"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}}, + indent=2, + ) + + "\n" + ) + + +def print_or_write_opencode_config( + *, + config: HostServerConfig, + output_path: Path | None = None, +) -> dict[str, object]: + rendered = render_opencode_config(config) + if output_path is None: + return { + "host": "opencode", + "rendered_config": rendered, + "server_command": _canonical_server_command(config), + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(rendered, encoding="utf-8") + return { + "host": "opencode", + "output_path": str(output_path), + "server_command": _canonical_server_command(config), + } + + +def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]: + binary = _host_binary(host) + if shutil.which(binary) is None: + raise RuntimeError(f"{binary} CLI is not installed or not on PATH") + server_command = _canonical_server_command(config) + _run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME]) + result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command]) + if result.returncode != 0: + details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed" + raise RuntimeError(details) + return { + "host": host, + "server_command": server_command, + "verification_command": [binary, "mcp", "list"], + } + + +def repair_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + resolved_path.parent.mkdir(parents=True, exist_ok=True) + payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config) + resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + result: dict[str, object] = { + "host": "opencode", + "config_path": str(resolved_path), + "server_command": _canonical_server_command(config), + } + if backup_path is not None: + result["backup_path"] = str(backup_path) + return result + + +def repair_host( + host: str, + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> dict[str, object]: + if host == "opencode": + return repair_opencode_host(config=config, config_path=config_path) + return connect_cli_host(host, config=config) + + +def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry: + binary = _host_binary(host) + repair_command = _repair_command(host, config) + if shutil.which(binary) is None: + return HostDoctorEntry( + host=host, + installed=False, + configured=False, + status="unavailable", + details=f"{binary} CLI was not found on PATH", + repair_command=repair_command, + ) + expected_command = _canonical_server_command(config) + get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME]) + combined_get_output = (get_result.stdout + get_result.stderr).strip() + if get_result.returncode == 0: + status: HostStatus = ( + "ok" if _command_matches(combined_get_output, expected_command) else "drifted" + ) + return HostDoctorEntry( + host=host, + installed=True, + configured=True, + status=status, + details=combined_get_output or f"{binary} MCP entry exists", + repair_command=repair_command, + ) + + list_result = _run_command([binary, "mcp", "list"]) + combined_list_output = (list_result.stdout + list_result.stderr).strip() + configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split() + return HostDoctorEntry( + host=host, + installed=True, + configured=configured, + status="drifted" if configured else "missing", + details=combined_get_output or combined_list_output or f"{binary} MCP entry missing", + repair_command=repair_command, + ) + + +def _doctor_opencode_host( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> HostDoctorEntry: + resolved_path = ( + DEFAULT_OPENCODE_CONFIG_PATH + if config_path is None + else config_path.expanduser().resolve() + ) + repair_command = _repair_command("opencode", config, config_path=config_path) + installed = shutil.which("opencode") is not None + if not resolved_path.exists(): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config missing at {resolved_path}", + repair_command=repair_command, + ) + try: + payload = json.loads(resolved_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details=f"OpenCode config is invalid JSON: {exc}", + repair_command=repair_command, + ) + if not isinstance(payload, dict): + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="drifted" if installed else "unavailable", + details="OpenCode config must be a JSON object", + repair_command=repair_command, + ) + mcp_payload = payload.get("mcp") + if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload: + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=False, + status="missing" if installed else "unavailable", + details=f"OpenCode config at {resolved_path} is missing mcp.pyro", + repair_command=repair_command, + ) + configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME] + expected_entry = canonical_opencode_entry(config) + status: HostStatus = "ok" if configured_entry == expected_entry else "drifted" + return HostDoctorEntry( + host="opencode", + installed=installed, + configured=True, + status=status, + details=f"OpenCode config path: {resolved_path}", + repair_command=repair_command, + ) + + +def doctor_hosts( + *, + config: HostServerConfig, + config_path: Path | None = None, +) -> list[HostDoctorEntry]: + return [ + _doctor_cli_host("claude-code", config=config), + _doctor_cli_host("codex", config=config), + _doctor_opencode_host(config=config, config_path=config_path), + ] diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 1ba41bb..a6cbd46 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.1.0" +DEFAULT_CATALOG_VERSION = "4.2.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.1.0,<5.0.0" + compatibility: str = ">=4.2.0,<5.0.0" @dataclass(frozen=True) diff --git a/tests/test_api.py b/tests/test_api.py index 29bda64..538e419 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ from pyro_mcp.vm_network import TapNetworkManager def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 - ["git", *args], + ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, diff --git a/tests/test_cli.py b/tests/test_cli.py index 4cd085c..b55e752 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ from typing import Any, cast import pytest import pyro_mcp.cli as cli +from pyro_mcp.host_helpers import HostDoctorEntry def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser: @@ -31,10 +32,11 @@ def test_cli_help_guides_first_run() -> None: assert "pyro env list" in help_text assert "pyro env pull debian:12" in help_text assert "pyro run debian:12 -- git --version" in help_text - assert "pyro mcp serve" in help_text + assert "pyro host connect claude-code" in help_text assert "Connect a chat host after that:" in help_text - assert "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text - assert "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" in help_text + assert "pyro host connect claude-code" in help_text + assert "pyro host connect codex" in help_text + assert "pyro host print-config opencode" in help_text assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text @@ -57,6 +59,31 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro env pull debian:12" in env_help assert "downloads from public Docker Hub" in env_help + host_help = _subparser_choice(parser, "host").format_help() + assert "Connect or repair the supported Claude Code, Codex, and OpenCode" in host_help + assert "pyro host connect claude-code" in host_help + assert "pyro host repair opencode" in host_help + + host_connect_help = _subparser_choice( + _subparser_choice(parser, "host"), "connect" + ).format_help() + assert "--installed-package" in host_connect_help + assert "--project-path" in host_connect_help + assert "--repo-url" in host_connect_help + assert "--repo-ref" in host_connect_help + assert "--no-project-source" in host_connect_help + + host_print_config_help = _subparser_choice( + _subparser_choice(parser, "host"), "print-config" + ).format_help() + assert "--output" in host_print_config_help + + host_doctor_help = _subparser_choice(_subparser_choice(parser, "host"), "doctor").format_help() + assert "--config-path" in host_doctor_help + + host_repair_help = _subparser_choice(_subparser_choice(parser, "host"), "repair").format_help() + assert "--config-path" in host_repair_help + doctor_help = _subparser_choice(parser, "doctor").format_help() assert "Check host prerequisites and embedded runtime health" in doctor_help assert "pyro doctor --json" in doctor_help @@ -316,6 +343,94 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "Close a persistent workspace shell" in workspace_shell_close_help +def test_cli_host_connect_dispatch( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + pass + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="host", + host_command="connect", + host="codex", + installed_package=False, + profile="workspace-core", + project_path=None, + repo_url=None, + repo_ref=None, + no_project_source=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr( + cli, + "connect_cli_host", + lambda host, *, config: { + "host": host, + "server_command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + "verification_command": ["codex", "mcp", "list"], + }, + ) + + cli.main() + captured = capsys.readouterr() + assert captured.out == ( + "Connected pyro to codex.\n" + "Server command: uvx --from pyro-mcp pyro mcp serve\n" + "Verify with: codex mcp list\n" + ) + assert captured.err == "" + + +def test_cli_host_doctor_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + pass + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="host", + host_command="doctor", + installed_package=False, + profile="workspace-core", + project_path=None, + repo_url=None, + repo_ref=None, + no_project_source=False, + config_path=None, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr( + cli, + "doctor_hosts", + lambda **_: [ + HostDoctorEntry( + host="codex", + installed=True, + configured=False, + status="missing", + details="codex entry missing", + repair_command="pyro host repair codex", + ) + ], + ) + + cli.main() + captured = capsys.readouterr() + assert "codex: missing installed=yes configured=no" in captured.out + assert "repair: pyro host repair codex" in captured.out + assert captured.err == "" + + def test_cli_run_prints_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -2823,30 +2938,38 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8") codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8") opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8")) + claude_helper = "pyro host connect claude-code" + codex_helper = "pyro host connect codex" + opencode_helper = "pyro host print-config opencode" claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" assert "## Chat Host Quickstart" in readme - assert "uvx --from pyro-mcp pyro mcp serve" in readme - assert claude_cmd in readme - assert codex_cmd in readme + assert claude_helper in readme + assert codex_helper in readme + assert opencode_helper in readme assert "examples/opencode_mcp_config.json" in readme + assert "pyro host doctor" in readme assert "bare `pyro mcp serve` starts `workspace-core`" in readme - assert "auto-detects\nthe current Git checkout" in readme + assert "auto-detects the current Git checkout" in readme.replace("\n", " ") assert "--project-path /abs/path/to/repo" in readme assert "--repo-url https://github.com/example/project.git" in readme assert "## 5. Connect a chat host" in install - assert "uvx --from pyro-mcp pyro mcp serve" in install - assert claude_cmd in install - assert codex_cmd in install + assert claude_helper in install + assert codex_helper in install + assert opencode_helper in install assert "workspace-full" in install assert "--project-path /abs/path/to/repo" in install - assert claude_cmd in first_run - assert codex_cmd in first_run + assert claude_helper in first_run + assert codex_helper in first_run + assert opencode_helper in first_run assert "--project-path /abs/path/to/repo" in first_run + assert claude_helper in integrations + assert codex_helper in integrations + assert opencode_helper in integrations assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations assert "auto-detects the current Git checkout" in integrations assert "examples/claude_code_mcp.md" in integrations @@ -2862,13 +2985,17 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert "codex_mcp.md" in mcp_config assert "opencode_mcp_config.json" in mcp_config + assert claude_helper in claude_code assert claude_cmd in claude_code assert "claude mcp list" in claude_code + assert "pyro host repair claude-code" in claude_code assert "workspace-full" in claude_code assert "--project-path /abs/path/to/repo" in claude_code + assert codex_helper in codex assert codex_cmd in codex assert "codex mcp list" in codex + assert "pyro host repair codex" in codex assert "workspace-full" in codex assert "--project-path /abs/path/to/repo" in codex diff --git a/tests/test_host_helpers.py b/tests/test_host_helpers.py new file mode 100644 index 0000000..0f84ae1 --- /dev/null +++ b/tests/test_host_helpers.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path +from subprocess import CompletedProcess + +import pytest + +import pyro_mcp.host_helpers as host_helpers +from pyro_mcp.host_helpers import ( + DEFAULT_OPENCODE_CONFIG_PATH, + HostServerConfig, + _canonical_server_command, + _command_matches, + _repair_command, + connect_cli_host, + doctor_hosts, + print_or_write_opencode_config, + repair_host, +) + + +def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]: + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + state_path = tmp_path / f"{name}-state.json" + script_path = bin_dir / name + script_path.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import json", + "import shlex", + "import sys", + f"STATE_PATH = {str(state_path)!r}", + "try:", + " with open(STATE_PATH, 'r', encoding='utf-8') as handle:", + " state = json.load(handle)", + "except FileNotFoundError:", + " state = {}", + "args = sys.argv[1:]", + "if args[:2] == ['mcp', 'add']:", + " name = args[2]", + " marker = args.index('--')", + " state[name] = args[marker + 1:]", + " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", + " json.dump(state, handle)", + " print(f'added {name}')", + " raise SystemExit(0)", + "if args[:2] == ['mcp', 'remove']:", + " name = args[2]", + " if name in state:", + " del state[name]", + " with open(STATE_PATH, 'w', encoding='utf-8') as handle:", + " json.dump(state, handle)", + " print(f'removed {name}')", + " raise SystemExit(0)", + " print('not found', file=sys.stderr)", + " raise SystemExit(1)", + "if args[:2] == ['mcp', 'get']:", + " name = args[2]", + " if name not in state:", + " print('not found', file=sys.stderr)", + " raise SystemExit(1)", + " print(f'{name}: {shlex.join(state[name])}')", + " raise SystemExit(0)", + "if args[:2] == ['mcp', 'list']:", + " for item in sorted(state):", + " print(item)", + " raise SystemExit(0)", + "print('unsupported', file=sys.stderr)", + "raise SystemExit(2)", + ] + ), + encoding="utf-8", + ) + script_path.chmod(0o755) + return bin_dir, state_path + + +def test_connect_cli_host_replaces_existing_entry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex") + state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8") + monkeypatch.setenv("PATH", str(bin_dir)) + + payload = connect_cli_host("codex", config=HostServerConfig()) + + assert payload["host"] == "codex" + assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + assert json.loads(state_path.read_text(encoding="utf-8")) == { + "pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + } + + +def test_canonical_server_command_validates_and_renders_variants() -> None: + assert _canonical_server_command(HostServerConfig(installed_package=True)) == [ + "pyro", + "mcp", + "serve", + ] + assert _canonical_server_command( + HostServerConfig(profile="workspace-full", project_path="/repo") + ) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "workspace-full", + "--project-path", + "/repo", + ] + assert _canonical_server_command( + HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main") + ) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--repo-url", + "https://example.com/repo.git", + "--repo-ref", + "main", + ] + assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--no-project-source", + ] + + with pytest.raises(ValueError, match="mutually exclusive"): + _canonical_server_command( + HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git") + ) + with pytest.raises(ValueError, match="cannot be combined"): + _canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True)) + with pytest.raises(ValueError, match="requires --repo-url"): + _canonical_server_command(HostServerConfig(repo_ref="main")) + + +def test_repair_command_and_command_matches_cover_edge_cases() -> None: + assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex" + assert _repair_command("codex", HostServerConfig(project_path="/repo")) == ( + "pyro host repair codex --project-path /repo" + ) + assert _repair_command( + "opencode", + HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"), + config_path=Path("/tmp/opencode.json"), + ) == ( + "pyro host repair opencode --installed-package --profile workspace-full " + "--repo-url file:///repo --config-path /tmp/opencode.json" + ) + assert _repair_command("codex", HostServerConfig(no_project_source=True)) == ( + "pyro host repair codex --no-project-source" + ) + assert _command_matches( + "pyro: uvx --from pyro-mcp pyro mcp serve", + ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + ) + assert _command_matches( + '"uvx --from pyro-mcp pyro mcp serve', + ['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"], + ) + assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + ]) + + +def test_connect_cli_host_reports_missing_cli_and_add_failure( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match="unsupported CLI host"): + connect_cli_host("unsupported", config=HostServerConfig()) + + monkeypatch.setenv("PATH", "") + with pytest.raises(RuntimeError, match="codex CLI is not installed"): + connect_cli_host("codex", config=HostServerConfig()) + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script_path = bin_dir / "codex" + script_path.write_text( + "\n".join( + [ + f"#!{sys.executable}", + "import sys", + "raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)", + ] + ), + encoding="utf-8", + ) + script_path.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + + with pytest.raises(RuntimeError, match="codex mcp add failed"): + connect_cli_host("codex", config=HostServerConfig()) + + +def test_doctor_hosts_reports_ok_and_drifted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex") + claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude") + combined_path = str(codex_bin) + ":" + str(claude_bin) + monkeypatch.setenv("PATH", combined_path) + + codex_state.write_text( + json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}), + encoding="utf-8", + ) + claude_state.write_text( + json.dumps( + { + "pyro": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "workspace-full", + ] + } + ), + encoding="utf-8", + ) + opencode_config = tmp_path / "opencode.json" + opencode_config.write_text( + json.dumps( + { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + } + } + } + ), + encoding="utf-8", + ) + + entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config) + by_host = {entry.host: entry for entry in entries} + + assert by_host["codex"].status == "ok" + assert by_host["codex"].configured is True + assert by_host["claude-code"].status == "drifted" + assert by_host["claude-code"].configured is True + assert by_host["opencode"].status == "ok" + assert by_host["opencode"].configured is True + + +def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + + config_path.write_text("[]", encoding="utf-8") + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "unavailable" + assert "JSON object" in by_host["opencode"].details + + config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8") + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "unavailable" + assert "missing mcp.pyro" in by_host["opencode"].details + + config_path.write_text( + json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}), + encoding="utf-8", + ) + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + assert by_host["opencode"].status == "drifted" + assert by_host["opencode"].configured is True + + +def test_doctor_hosts_reports_invalid_json_for_installed_opencode( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + config_path = tmp_path / "opencode.json" + config_path.write_text("{invalid", encoding="utf-8") + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/opencode" if name == "opencode" else None, + ) + + entries = doctor_hosts(config=HostServerConfig(), config_path=config_path) + by_host = {entry.host: entry for entry in entries} + + assert by_host["opencode"].status == "drifted" + assert "invalid JSON" in by_host["opencode"].details + + +def test_repair_opencode_preserves_unrelated_keys( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text( + json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}), + encoding="utf-8", + ) + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + assert payload["config_path"] == str(config_path.resolve()) + repaired = json.loads(config_path.read_text(encoding="utf-8")) + assert repaired["theme"] == "light" + assert repaired["mcp"]["other"] == {"type": "local"} + assert repaired["mcp"]["pyro"] == { + "type": "local", + "enabled": True, + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], + } + + +def test_repair_opencode_backs_up_non_object_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text("[]", encoding="utf-8") + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + backup_path = Path(str(payload["backup_path"])) + assert backup_path.exists() + assert backup_path.read_text(encoding="utf-8") == "[]" + + +def test_repair_opencode_backs_up_invalid_json( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + config_path = tmp_path / "opencode.json" + config_path.write_text("{invalid", encoding="utf-8") + + payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path) + + backup_path = Path(str(payload["backup_path"])) + assert backup_path.exists() + assert backup_path.read_text(encoding="utf-8") == "{invalid" + repaired = json.loads(config_path.read_text(encoding="utf-8")) + assert repaired["mcp"]["pyro"]["command"] == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + ] + + +def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None: + output_path = tmp_path / "opencode.json" + payload = print_or_write_opencode_config( + config=HostServerConfig(project_path="/repo"), + output_path=output_path, + ) + + assert payload["output_path"] == str(output_path) + rendered = json.loads(output_path.read_text(encoding="utf-8")) + assert rendered == { + "mcp": { + "pyro": { + "type": "local", + "enabled": True, + "command": [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--project-path", + "/repo", + ], + } + } + } + + +def test_print_or_write_opencode_config_returns_rendered_text() -> None: + payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run")) + + assert payload["host"] == "opencode" + assert payload["server_command"] == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--profile", + "vm-run", + ] + rendered = str(payload["rendered_config"]) + assert '"type": "local"' in rendered + assert '"command": [' in rendered + + +def test_doctor_reports_opencode_missing_when_config_absent( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PATH", "") + + entries = doctor_hosts( + config=HostServerConfig(), + config_path=tmp_path / "missing-opencode.json", + ) + by_host = {entry.host: entry for entry in entries} + + assert by_host["opencode"].status == "unavailable" + assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details + + +def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + host_helpers, + "connect_cli_host", + lambda host, *, config: {"host": host, "profile": config.profile}, + ) + assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == { + "host": "codex", + "profile": "vm-run", + } + + commands: list[list[str]] = [] + + def _fake_run_command(command: list[str]) -> CompletedProcess[str]: + commands.append(command) + if command[:3] == ["codex", "mcp", "get"]: + return CompletedProcess(command, 1, "", "not found") + if command[:3] == ["codex", "mcp", "list"]: + return CompletedProcess(command, 0, "pyro\n", "") + raise AssertionError(command) + + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/codex" if name == "codex" else None, + ) + monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command) + + entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig()) + assert entry.status == "drifted" + assert entry.configured is True + assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]] diff --git a/tests/test_project_startup.py b/tests/test_project_startup.py index 667c05f..fd8a6b1 100644 --- a/tests/test_project_startup.py +++ b/tests/test_project_startup.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest +import pyro_mcp.project_startup as project_startup from pyro_mcp.project_startup import ( ProjectStartupSource, describe_project_startup_source, @@ -15,7 +16,7 @@ from pyro_mcp.project_startup import ( def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 - ["git", *args], + ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, @@ -76,6 +77,52 @@ def test_resolve_project_startup_source_validates_flag_combinations(tmp_path: Pa resolve_project_startup_source(project_path=repo, no_project_source=True) +def test_resolve_project_startup_source_handles_explicit_none_and_empty_values( + tmp_path: Path, +) -> None: + repo = _make_repo(tmp_path / "repo") + outside = tmp_path / "outside" + outside.mkdir() + + assert resolve_project_startup_source(no_project_source=True, cwd=tmp_path) is None + assert resolve_project_startup_source(cwd=outside) is None + + with pytest.raises(ValueError, match="must not be empty"): + resolve_project_startup_source(repo_url=" ", cwd=repo) + + with pytest.raises(ValueError, match="must not be empty"): + resolve_project_startup_source(repo_url="https://example.com/repo.git", repo_ref=" ") + + +def test_resolve_project_startup_source_rejects_missing_or_non_directory_project_path( + tmp_path: Path, +) -> None: + missing = tmp_path / "missing" + file_path = tmp_path / "note.txt" + file_path.write_text("hello\n", encoding="utf-8") + + with pytest.raises(ValueError, match="does not exist"): + resolve_project_startup_source(project_path=missing, cwd=tmp_path) + + with pytest.raises(ValueError, match="must be a directory"): + resolve_project_startup_source(project_path=file_path, cwd=tmp_path) + + +def test_resolve_project_startup_source_keeps_plain_relative_directory_when_not_a_repo( + tmp_path: Path, +) -> None: + plain = tmp_path / "plain" + plain.mkdir() + + resolved = resolve_project_startup_source(project_path="plain", cwd=tmp_path) + + assert resolved == ProjectStartupSource( + kind="project_path", + origin_ref=str(plain.resolve()), + resolved_path=plain.resolve(), + ) + + def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo", content="one\n") first_commit = _git(repo, "rev-parse", "HEAD") @@ -93,6 +140,60 @@ def test_materialize_project_startup_source_clones_local_repo_url_at_ref(tmp_pat assert (clone_dir / "note.txt").read_text(encoding="utf-8") == "one\n" +def test_materialize_project_startup_source_validates_project_source_and_clone_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + with pytest.raises(RuntimeError, match="missing a resolved path"): + with materialize_project_startup_source( + ProjectStartupSource(kind="project_path", origin_ref="/repo", resolved_path=None) + ): + pass + + source = ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") + + def _clone_failure( + command: list[str], + *, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + del cwd + return subprocess.CompletedProcess(command, 1, "", "clone failed") + + monkeypatch.setattr("pyro_mcp.project_startup._run_git", _clone_failure) + with pytest.raises(RuntimeError, match="failed to clone repo_url"): + with materialize_project_startup_source(source): + pass + + +def test_materialize_project_startup_source_reports_checkout_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + repo = _make_repo(tmp_path / "repo", content="one\n") + source = ProjectStartupSource( + kind="repo_url", + origin_ref=str(repo.resolve()), + repo_ref="missing-ref", + ) + + original_run_git = project_startup._run_git + + def _checkout_failure( + command: list[str], + *, + cwd: Path | None = None, + ) -> subprocess.CompletedProcess[str]: + if command[:2] == ["git", "checkout"]: + return subprocess.CompletedProcess(command, 1, "", "checkout failed") + return original_run_git(command, cwd=cwd) + + monkeypatch.setattr("pyro_mcp.project_startup._run_git", _checkout_failure) + with pytest.raises(RuntimeError, match="failed to checkout repo_ref"): + with materialize_project_startup_source(source): + pass + + def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_path: Path) -> None: repo = _make_repo(tmp_path / "repo") @@ -113,3 +214,23 @@ def test_describe_project_startup_source_formats_project_and_repo_sources(tmp_pa assert project_description == f"the current project at {repo.resolve()}" assert repo_description == "the clean clone source https://example.com/repo.git at ref main" + + +def test_describe_project_startup_source_handles_none_and_repo_without_ref() -> None: + assert describe_project_startup_source(None) is None + assert ( + describe_project_startup_source( + ProjectStartupSource(kind="repo_url", origin_ref="https://example.com/repo.git") + ) + == "the clean clone source https://example.com/repo.git" + ) + + +def test_detect_git_root_returns_none_for_empty_stdout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + project_startup, + "_run_git", + lambda command, *, cwd=None: subprocess.CompletedProcess(command, 0, "\n", ""), + ) + + assert project_startup._detect_git_root(Path.cwd()) is None diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 5033d4a..35aad8f 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -16,6 +16,11 @@ from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, PUBLIC_CLI_ENV_SUBCOMMANDS, + PUBLIC_CLI_HOST_CONNECT_FLAGS, + PUBLIC_CLI_HOST_DOCTOR_FLAGS, + PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS, + PUBLIC_CLI_HOST_REPAIR_FLAGS, + PUBLIC_CLI_HOST_SUBCOMMANDS, PUBLIC_CLI_MCP_SERVE_FLAGS, PUBLIC_CLI_MCP_SUBCOMMANDS, PUBLIC_CLI_RUN_FLAGS, @@ -102,6 +107,29 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text + host_help_text = _subparser_choice(parser, "host").format_help() + for subcommand_name in PUBLIC_CLI_HOST_SUBCOMMANDS: + assert subcommand_name in host_help_text + host_connect_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "connect" + ).format_help() + for flag in PUBLIC_CLI_HOST_CONNECT_FLAGS: + assert flag in host_connect_help_text + host_doctor_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "doctor" + ).format_help() + for flag in PUBLIC_CLI_HOST_DOCTOR_FLAGS: + assert flag in host_doctor_help_text + host_print_config_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "print-config" + ).format_help() + for flag in PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS: + assert flag in host_print_config_help_text + host_repair_help_text = _subparser_choice( + _subparser_choice(parser, "host"), "repair" + ).format_help() + for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS: + assert flag in host_repair_help_text mcp_help_text = _subparser_choice(parser, "mcp").format_help() for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: assert subcommand_name in mcp_help_text diff --git a/tests/test_server.py b/tests/test_server.py index f36c1ff..e52c2d0 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -19,7 +19,7 @@ from pyro_mcp.vm_network import TapNetworkManager def _git(repo: Path, *args: str) -> str: result = subprocess.run( # noqa: S603 - ["git", *args], + ["git", "-c", "commit.gpgsign=false", *args], cwd=repo, check=True, capture_output=True, diff --git a/tests/test_workspace_ports.py b/tests/test_workspace_ports.py index d63cfae..54a3fbe 100644 --- a/tests/test_workspace_ports.py +++ b/tests/test_workspace_ports.py @@ -15,6 +15,13 @@ import pytest from pyro_mcp import workspace_ports +def _socketpair_or_skip() -> tuple[socket.socket, socket.socket]: + try: + return socket.socketpair() + except PermissionError as exc: + pytest.skip(f"socketpair unavailable in this environment: {exc}") + + class _EchoHandler(socketserver.BaseRequestHandler): def handle(self) -> None: data = self.request.recv(65536) @@ -50,18 +57,26 @@ def test_workspace_port_proxy_handler_ignores_upstream_connect_failure( def test_workspace_port_proxy_forwards_tcp_traffic() -> None: - upstream = socketserver.ThreadingTCPServer( - (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), - _EchoHandler, - ) + try: + upstream = socketserver.ThreadingTCPServer( + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + _EchoHandler, + ) + except PermissionError as exc: + pytest.skip(f"TCP bind unavailable in this environment: {exc}") upstream_thread = threading.Thread(target=upstream.serve_forever, daemon=True) upstream_thread.start() upstream_host = str(upstream.server_address[0]) upstream_port = int(upstream.server_address[1]) - proxy = workspace_ports._ProxyServer( # noqa: SLF001 - (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), - (upstream_host, upstream_port), - ) + try: + proxy = workspace_ports._ProxyServer( # noqa: SLF001 + (workspace_ports.DEFAULT_PUBLISHED_PORT_HOST, 0), + (upstream_host, upstream_port), + ) + except PermissionError as exc: + upstream.shutdown() + upstream.server_close() + pytest.skip(f"proxy TCP bind unavailable in this environment: {exc}") proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) proxy_thread.start() try: @@ -202,8 +217,8 @@ def test_workspace_ports_main_shutdown_handler_stops_server( def test_workspace_port_proxy_handler_handles_empty_and_invalid_selector_events( monkeypatch: Any, ) -> None: - source, source_peer = socket.socketpair() - upstream, upstream_peer = socket.socketpair() + source, source_peer = _socketpair_or_skip() + upstream, upstream_peer = _socketpair_or_skip() source_peer.close() class FakeSelector: @@ -246,10 +261,17 @@ def test_workspace_port_proxy_handler_handles_recv_and_send_errors( monkeypatch: Any, ) -> None: def _run_once(*, close_source: bool) -> None: - source, source_peer = socket.socketpair() - upstream, upstream_peer = socket.socketpair() + source, source_peer = _socketpair_or_skip() + upstream, upstream_peer = _socketpair_or_skip() if not close_source: - source_peer.sendall(b"hello") + try: + source_peer.sendall(b"hello") + except PermissionError as exc: + source.close() + source_peer.close() + upstream.close() + upstream_peer.close() + pytest.skip(f"socket send unavailable in this environment: {exc}") class FakeSelector: def register(self, *_args: Any, **_kwargs: Any) -> None: diff --git a/uv.lock b/uv.lock index eb006fe..cc18ce9 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.1.0" +version = "4.2.0" source = { editable = "." } dependencies = [ { name = "mcp" }, From dc86d84e96f8a94e3f1c5a23a19d486aee5398a6 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 19:21:11 -0300 Subject: [PATCH 5/8] Add workspace review summaries Add workspace summary across the CLI, SDK, and MCP, and include it in the workspace-core profile so chat hosts can review one concise view of the current session. Persist lightweight review events for syncs, file edits, patch applies, exports, service lifecycle, and snapshot activity, then synthesize them with command history, current services, snapshot state, and current diff data since the last reset. Update the walkthroughs, use-case docs, public contract, changelog, and roadmap for 4.3.0, and make dist-check invoke the CLI module directly so local package reinstall quirks do not break the packaging gate. Validation: uv lock; ./.venv/bin/pytest --no-cov tests/test_vm_manager.py tests/test_cli.py tests/test_api.py tests/test_server.py tests/test_public_contract.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed workspace create -> patch apply -> workspace summary --json -> delete smoke. --- CHANGELOG.md | 11 + Makefile | 18 +- README.md | 5 +- docs/first-run.md | 3 +- docs/install.md | 3 +- docs/public-contract.md | 2 + docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.3.0-reviewable-agent-output.md | 2 +- docs/use-cases/README.md | 6 + docs/use-cases/review-eval-workflows.md | 7 +- pyproject.toml | 2 +- src/pyro_mcp/api.py | 10 + src/pyro_mcp/cli.py | 177 +++++++++ src/pyro_mcp/contract.py | 5 + src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/vm_manager.py | 349 +++++++++++++++++- src/pyro_mcp/workspace_use_case_smokes.py | 5 + tests/test_api.py | 28 +- tests/test_cli.py | 182 +++++++++ tests/test_public_contract.py | 6 + tests/test_server.py | 9 + tests/test_vm_manager.py | 118 ++++++ tests/test_workspace_use_case_smokes.py | 63 ++++ uv.lock | 2 +- 24 files changed, 994 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d30ab17..6fa1330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.3.0 + +- Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP + `workspace_summary` so users and chat hosts can review a concise view of the + current workspace session since the last reset. +- Added a lightweight review-event log for edits, syncs, exports, service + lifecycle, and snapshot activity without duplicating the command journal. +- Updated the main workspace walkthroughs and review/eval recipe so + `workspace summary` is the first review surface before dropping down to raw + diffs, logs, and exported files. + ## 4.2.0 - Added host bootstrap and repair helpers with `pyro host connect`, diff --git a/Makefile b/Makefile index 46ebd93..c76fe4e 100644 --- a/Makefile +++ b/Makefile @@ -83,15 +83,15 @@ test: check: lint typecheck test dist-check: - uv run pyro --version - uv run pyro --help >/dev/null - uv run pyro host --help >/dev/null - uv run pyro host doctor >/dev/null - uv run pyro mcp --help >/dev/null - uv run pyro run --help >/dev/null - uv run pyro env list >/dev/null - uv run pyro env inspect debian:12 >/dev/null - uv run pyro doctor >/dev/null + uv run python -m pyro_mcp.cli --version + uv run python -m pyro_mcp.cli --help >/dev/null + uv run python -m pyro_mcp.cli host --help >/dev/null + uv run python -m pyro_mcp.cli host doctor >/dev/null + uv run python -m pyro_mcp.cli mcp --help >/dev/null + uv run python -m pyro_mcp.cli run --help >/dev/null + uv run python -m pyro_mcp.cli env list >/dev/null + uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null + uv run python -m pyro_mcp.cli doctor >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ diff --git a/README.md b/README.md index 8ab2054..74f0965 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.2.0: [CHANGELOG.md#420](CHANGELOG.md#420) +- What's new in 4.3.0: [CHANGELOG.md#430](CHANGELOG.md#430) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -76,7 +76,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.2.0 +Catalog version: 4.3.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -207,6 +207,7 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace summary "$WORKSPACE_ID" pyro workspace snapshot create "$WORKSPACE_ID" checkpoint pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt diff --git a/docs/first-run.md b/docs/first-run.md index 6fa3442..9e26999 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.2.0 +Catalog version: 4.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -155,6 +155,7 @@ $ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes $ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt --content-only $ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch $ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +$ uvx --from pyro-mcp pyro workspace summary "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint $ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt diff --git a/docs/install.md b/docs/install.md index 1879146..4a57178 100644 --- a/docs/install.md +++ b/docs/install.md @@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.2.0 +Catalog version: 4.3.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -239,6 +239,7 @@ pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt +pyro workspace summary "$WORKSPACE_ID" pyro workspace snapshot create "$WORKSPACE_ID" checkpoint pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt diff --git a/docs/public-contract.md b/docs/public-contract.md index b061cd5..e1e0856 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -117,6 +117,7 @@ setup and repair path for supported hosts. - `workspace_sync_push` - `workspace_exec` - `workspace_logs` +- `workspace_summary` - `workspace_file_list` - `workspace_file_read` - `workspace_file_write` @@ -133,6 +134,7 @@ That is enough for the normal persistent editing loop: - sync or seed repo content - inspect and edit files without shell quoting - run commands repeatedly in one sandbox +- review the current session in one concise summary - diff and export results - reset and retry - delete the workspace when the task is done diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index ff6ccc6..60b85b6 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.2.0`: +Current baseline is `4.3.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -81,7 +81,7 @@ capability gaps: 11. [`4.0.0` Workspace-Core Default Profile](llm-chat-ergonomics/4.0.0-workspace-core-default-profile.md) - Done 12. [`4.1.0` Project-Aware Chat Startup](llm-chat-ergonomics/4.1.0-project-aware-chat-startup.md) - Done 13. [`4.2.0` Host Bootstrap And Repair](llm-chat-ergonomics/4.2.0-host-bootstrap-and-repair.md) - 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) - Done 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 @@ -120,10 +120,12 @@ Completed so far: - `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. +- `4.3.0` adds a concise workspace review surface so users can inspect what the + agent changed and ran since the last reset without reconstructing the + session from several lower-level views by hand. Planned next: -- [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) diff --git a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md index 838a28c..b1b02ba 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md +++ b/docs/roadmap/llm-chat-ergonomics/4.3.0-reviewable-agent-output.md @@ -1,6 +1,6 @@ # `4.3.0` Reviewable Agent Output -Status: Planned +Status: Done ## Goal diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index 0f93188..ec249b7 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -30,3 +30,9 @@ That runner generates its own host fixtures, creates real guest-backed workspace verifies the intended flow, exports one concrete result when relevant, and cleans up on both success and failure. Treat `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised workspace workflows. + +For a concise review before exporting, resetting, or handing work off, use: + +```bash +pyro workspace summary WORKSPACE_ID +``` diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index 1d8d12e..3ab9204 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -16,9 +16,10 @@ Chat-host recipe: 1. Create a named snapshot before the review starts. 2. Open a readable PTY shell and inspect the checklist interactively. 3. Run the review or evaluation script in the same workspace. -4. Export the final report. -5. Reset back to the snapshot if the review branch goes sideways. -6. Delete the workspace when the evaluation is done. +4. Capture `workspace summary` to review what changed and what to export. +5. Export the final report. +6. Reset back to the snapshot if the review branch goes sideways. +7. Delete the workspace when the evaluation is done. This is the stable shell-facing story: readable PTY output for chat loops, checkpointed evaluation, explicit export, and reset when a review branch goes diff --git a/pyproject.toml b/pyproject.toml index 1d6b8e0..963ecdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.2.0" +version = "4.3.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 35feeb9..935c619 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -204,6 +204,9 @@ class Pyro: def logs_workspace(self, workspace_id: str) -> dict[str, Any]: return self._manager.logs_workspace(workspace_id) + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + return self._manager.summarize_workspace(workspace_id) + def export_workspace( self, workspace_id: str, @@ -818,6 +821,13 @@ class Pyro: """Return persisted command history for one workspace.""" return self.logs_workspace(workspace_id) + if _enabled("workspace_summary"): + + @server.tool() + async def workspace_summary(workspace_id: str) -> dict[str, Any]: + """Summarize the current workspace session for human review.""" + return self.summarize_workspace(workspace_id) + if _enabled("workspace_export"): @server.tool() diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 204df2a..cfb6c86 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -550,6 +550,147 @@ def _print_workspace_logs_human(payload: dict[str, Any]) -> None: print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) +def _print_workspace_review_summary_human(payload: dict[str, Any]) -> None: + print(f"Workspace review: {str(payload.get('workspace_id', 'unknown'))}") + name = payload.get("name") + if isinstance(name, str) and name != "": + print(f"Name: {name}") + labels = payload.get("labels") + if isinstance(labels, dict) and labels: + rendered_labels = ", ".join( + f"{str(key)}={str(value)}" for key, value in sorted(labels.items()) + ) + print(f"Labels: {rendered_labels}") + print(f"Environment: {str(payload.get('environment', 'unknown'))}") + print(f"State: {str(payload.get('state', 'unknown'))}") + print(f"Last activity at: {payload.get('last_activity_at')}") + print(f"Session started at: {payload.get('session_started_at')}") + + outcome = payload.get("outcome") + if isinstance(outcome, dict): + print( + "Outcome: " + f"commands={int(outcome.get('command_count', 0))} " + f"services={int(outcome.get('running_service_count', 0))}/" + f"{int(outcome.get('service_count', 0))} " + f"exports={int(outcome.get('export_count', 0))} " + f"snapshots={int(outcome.get('snapshot_count', 0))} " + f"resets={int(outcome.get('reset_count', 0))}" + ) + last_command = outcome.get("last_command") + if isinstance(last_command, dict): + print( + "Last command: " + f"{str(last_command.get('command', 'unknown'))} " + f"(exit_code={int(last_command.get('exit_code', -1))})" + ) + + def _print_events(title: str, events: object, *, formatter: Any) -> None: + if not isinstance(events, list) or not events: + return + print(f"{title}:") + for event in events: + if not isinstance(event, dict): + continue + print(f"- {formatter(event)}") + + commands = payload.get("commands") + if isinstance(commands, dict): + _print_events( + "Recent commands", + commands.get("recent"), + formatter=lambda event: ( + f"#{int(event.get('sequence', 0))} " + f"exit={int(event.get('exit_code', -1))} " + f"cwd={str(event.get('cwd', WORKSPACE_GUEST_PATH))} " + f"cmd={str(event.get('command', ''))}" + ), + ) + + edits = payload.get("edits") + if isinstance(edits, dict): + _print_events( + "Recent edits", + edits.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'edit'))} " + f"at={event.get('recorded_at')} " + f"path={event.get('path') or event.get('destination') or 'n/a'}" + ), + ) + + changes = payload.get("changes") + if isinstance(changes, dict): + if not bool(changes.get("available")): + print(f"Changes: unavailable ({str(changes.get('reason', 'unknown reason'))})") + elif not bool(changes.get("changed")): + print("Changes: no current workspace changes.") + else: + summary = changes.get("summary") + if isinstance(summary, dict): + print( + "Changes: " + f"total={int(summary.get('total', 0))} " + f"added={int(summary.get('added', 0))} " + f"modified={int(summary.get('modified', 0))} " + f"deleted={int(summary.get('deleted', 0))} " + f"type_changed={int(summary.get('type_changed', 0))} " + f"non_text={int(summary.get('non_text', 0))}" + ) + _print_events( + "Top changed paths", + changes.get("entries"), + formatter=lambda event: ( + f"{str(event.get('status', 'changed'))} " + f"{str(event.get('path', 'unknown'))} " + f"[{str(event.get('artifact_type', 'unknown'))}]" + ), + ) + + services = payload.get("services") + if isinstance(services, dict): + _print_events( + "Current services", + services.get("current"), + formatter=lambda event: ( + f"{str(event.get('service_name', 'unknown'))} " + f"state={str(event.get('state', 'unknown'))}" + ), + ) + _print_events( + "Recent service events", + services.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'service'))} " + f"{str(event.get('service_name', 'unknown'))} " + f"state={str(event.get('state', 'unknown'))}" + ), + ) + + artifacts = payload.get("artifacts") + if isinstance(artifacts, dict): + _print_events( + "Recent exports", + artifacts.get("exports"), + formatter=lambda event: ( + f"{str(event.get('workspace_path', 'unknown'))} -> " + f"{str(event.get('output_path', 'unknown'))}" + ), + ) + + snapshots = payload.get("snapshots") + if isinstance(snapshots, dict): + print(f"Named snapshots: {int(snapshots.get('named_count', 0))}") + _print_events( + "Recent snapshot events", + snapshots.get("recent"), + formatter=lambda event: ( + f"{str(event.get('event_kind', 'snapshot'))} " + f"{str(event.get('snapshot_name', 'unknown'))}" + ), + ) + + def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None: snapshot = payload.get("snapshot") if not isinstance(snapshot, dict): @@ -765,6 +906,7 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes pyro workspace exec WORKSPACE_ID -- cat note.txt + pyro workspace summary WORKSPACE_ID pyro workspace diff WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint @@ -1178,6 +1320,7 @@ def _build_parser() -> argparse.ArgumentParser: pyro workspace start WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint + pyro workspace summary WORKSPACE_ID pyro workspace diff WORKSPACE_ID pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace shell open WORKSPACE_ID --id-only @@ -2317,6 +2460,33 @@ while true; do sleep 60; done' action="store_true", help="Print structured JSON instead of human-readable output.", ) + workspace_summary_parser = workspace_subparsers.add_parser( + "summary", + help="Summarize the current workspace session for review.", + description=( + "Summarize the current workspace session since the last reset, including recent " + "commands, edits, services, exports, snapshots, and current change status." + ), + epilog=dedent( + """ + Example: + pyro workspace summary WORKSPACE_ID + + Use `workspace logs`, `workspace diff`, and `workspace export` for drill-down. + """ + ), + formatter_class=_HelpFormatter, + ) + workspace_summary_parser.add_argument( + "workspace_id", + metavar="WORKSPACE_ID", + help="Persistent workspace identifier.", + ) + workspace_summary_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) workspace_logs_parser = workspace_subparsers.add_parser( "logs", help="Show command history for one workspace.", @@ -3305,6 +3475,13 @@ def main() -> None: else: _print_workspace_summary_human(payload, action="Workspace") return + if args.workspace_command == "summary": + payload = pyro.summarize_workspace(args.workspace_id) + if bool(args.json): + _print_json(payload) + else: + _print_workspace_review_summary_human(payload) + return if args.workspace_command == "logs": payload = pyro.logs_workspace(args.workspace_id) if bool(args.json): diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index f706633..cddbbff 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -44,6 +44,7 @@ PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "start", "status", "stop", + "summary", "sync", "update", ) @@ -120,6 +121,7 @@ PUBLIC_CLI_WORKSPACE_SNAPSHOT_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_START_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STATUS_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_STOP_FLAGS = ("--json",) +PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS = ("--dest", "--json") PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS = ( "--name", @@ -184,6 +186,7 @@ PUBLIC_SDK_METHODS = ( "stop_service", "stop_vm", "stop_workspace", + "summarize_workspace", "update_workspace", "write_shell", "write_workspace_file", @@ -228,6 +231,7 @@ PUBLIC_MCP_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", + "workspace_summary", "workspace_start", "workspace_status", "workspace_stop", @@ -249,6 +253,7 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = ( "workspace_logs", "workspace_patch_apply", "workspace_reset", + "workspace_summary", "workspace_status", "workspace_sync_push", "workspace_update", diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index a6cbd46..82375e1 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.2.0" +DEFAULT_CATALOG_VERSION = "4.3.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.2.0,<5.0.0" + compatibility: str = ">=4.3.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 4384d97..1e99682 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -79,12 +79,13 @@ DEFAULT_TIMEOUT_SECONDS = 30 DEFAULT_TTL_SECONDS = 600 DEFAULT_ALLOW_HOST_COMPAT = False -WORKSPACE_LAYOUT_VERSION = 8 +WORKSPACE_LAYOUT_VERSION = 9 WORKSPACE_BASELINE_DIRNAME = "baseline" WORKSPACE_BASELINE_ARCHIVE_NAME = "workspace.tar" WORKSPACE_SNAPSHOTS_DIRNAME = "snapshots" WORKSPACE_DIRNAME = "workspace" WORKSPACE_COMMANDS_DIRNAME = "commands" +WORKSPACE_REVIEW_DIRNAME = "review" WORKSPACE_SHELLS_DIRNAME = "shells" WORKSPACE_SERVICES_DIRNAME = "services" WORKSPACE_SECRETS_DIRNAME = "secrets" @@ -118,6 +119,16 @@ WORKSPACE_LABEL_KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") WorkspaceSeedMode = Literal["empty", "directory", "tar_archive"] WorkspaceSeedOriginKind = Literal["empty", "manual_seed_path", "project_path", "repo_url"] WorkspaceArtifactType = Literal["file", "directory", "symlink"] +WorkspaceReviewEventKind = Literal[ + "file_write", + "patch_apply", + "service_start", + "service_stop", + "snapshot_create", + "snapshot_delete", + "sync_push", + "workspace_export", +] WorkspaceServiceReadinessType = Literal["file", "tcp", "http", "command"] WorkspaceSnapshotKind = Literal["baseline", "named"] WorkspaceSecretSourceKind = Literal["literal", "file"] @@ -317,6 +328,35 @@ class WorkspaceSecretRecord: ) +@dataclass(frozen=True) +class WorkspaceReviewEventRecord: + """Persistent concise review event metadata stored on disk per workspace.""" + + workspace_id: str + event_kind: WorkspaceReviewEventKind + recorded_at: float + payload: dict[str, Any] + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": WORKSPACE_LAYOUT_VERSION, + "workspace_id": self.workspace_id, + "event_kind": self.event_kind, + "recorded_at": self.recorded_at, + "payload": self.payload, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> WorkspaceReviewEventRecord: + raw_payload = payload.get("payload") + return cls( + workspace_id=str(payload["workspace_id"]), + event_kind=cast(WorkspaceReviewEventKind, str(payload["event_kind"])), + recorded_at=float(payload["recorded_at"]), + payload=dict(raw_payload) if isinstance(raw_payload, dict) else {}, + ) + + @dataclass class WorkspaceSnapshotRecord: """Persistent snapshot metadata stored on disk per workspace.""" @@ -3775,6 +3815,7 @@ class VmManager: runtime_dir = self._workspace_runtime_dir(workspace_id) host_workspace_dir = self._workspace_host_dir(workspace_id) commands_dir = self._workspace_commands_dir(workspace_id) + review_dir = self._workspace_review_dir(workspace_id) shells_dir = self._workspace_shells_dir(workspace_id) services_dir = self._workspace_services_dir(workspace_id) secrets_dir = self._workspace_secrets_dir(workspace_id) @@ -3783,6 +3824,7 @@ class VmManager: workspace_dir.mkdir(parents=True, exist_ok=False) host_workspace_dir.mkdir(parents=True, exist_ok=True) commands_dir.mkdir(parents=True, exist_ok=True) + review_dir.mkdir(parents=True, exist_ok=True) shells_dir.mkdir(parents=True, exist_ok=True) services_dir.mkdir(parents=True, exist_ok=True) secrets_dir.mkdir(parents=True, exist_ok=True) @@ -3912,6 +3954,20 @@ class VmManager: workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="sync_push", + payload={ + "mode": str(workspace_sync["mode"]), + "source_path": str(workspace_sync["source_path"]), + "destination": str(workspace_sync["destination"]), + "entry_count": int(workspace_sync["entry_count"]), + "bytes_written": int(workspace_sync["bytes_written"]), + "execution_mode": str( + instance.metadata.get("execution_mode", "pending") + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -3993,8 +4049,8 @@ class VmManager: def export_workspace( self, workspace_id: str, - *, path: str, + *, output_path: str | Path, ) -> dict[str, Any]: normalized_path, _ = _normalize_workspace_destination(path) @@ -4026,6 +4082,23 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="workspace_export", + payload={ + "workspace_path": normalized_path, + "output_path": str(Path(str(extracted["output_path"]))), + "artifact_type": str(extracted["artifact_type"]), + "entry_count": int(extracted["entry_count"]), + "bytes_written": int(extracted["bytes_written"]), + "execution_mode": str( + exported.get( + "execution_mode", + instance.metadata.get("execution_mode", "pending"), + ) + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4185,6 +4258,22 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="file_write", + payload={ + "path": str(payload["path"]), + "size_bytes": int(payload["size_bytes"]), + "bytes_written": int(payload["bytes_written"]), + "execution_mode": str( + payload.get( + "execution_mode", + instance.metadata.get("execution_mode", "pending"), + ) + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4302,6 +4391,17 @@ class VmManager: workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="patch_apply", + payload={ + "summary": dict(summary), + "entries": [dict(entry) for entry in entries[:10]], + "execution_mode": str( + instance.metadata.get("execution_mode", "pending") + ), + }, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -4379,6 +4479,17 @@ class VmManager: self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_snapshot_locked(snapshot) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="snapshot_create", + payload={ + "snapshot_name": snapshot.snapshot_name, + "kind": snapshot.kind, + "entry_count": snapshot.entry_count, + "bytes_written": snapshot.bytes_written, + "created_at": snapshot.created_at, + }, + ) return { "workspace_id": workspace_id, "snapshot": self._serialize_workspace_snapshot(snapshot), @@ -4412,6 +4523,11 @@ class VmManager: self._load_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._delete_workspace_snapshot_locked(workspace_id, normalized_snapshot_name) self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="snapshot_delete", + payload={"snapshot_name": normalized_snapshot_name}, + ) self._save_workspace_locked(workspace) return { "workspace_id": workspace_id, @@ -5013,6 +5129,24 @@ class VmManager: self._touch_workspace_activity_locked(workspace) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="service_start", + payload={ + "service_name": service.service_name, + "state": service.state, + "command": service.command, + "cwd": service.cwd, + "readiness": ( + dict(service.readiness) if service.readiness is not None else None + ), + "ready_at": service.ready_at, + "published_ports": [ + _serialize_workspace_published_port_public(published_port) + for published_port in service.published_ports + ], + }, + ) return self._serialize_workspace_service(service) def list_services(self, workspace_id: str) -> dict[str, Any]: @@ -5132,6 +5266,18 @@ class VmManager: workspace.firecracker_pid = instance.firecracker_pid workspace.last_error = instance.last_error workspace.metadata = dict(instance.metadata) + self._touch_workspace_activity_locked(workspace) + self._record_workspace_review_event_locked( + workspace_id, + event_kind="service_stop", + payload={ + "service_name": service.service_name, + "state": service.state, + "exit_code": service.exit_code, + "stop_reason": service.stop_reason, + "ended_at": service.ended_at, + }, + ) self._save_workspace_locked(workspace) self._save_workspace_service_locked(service) return self._serialize_workspace_service(service) @@ -5165,6 +5311,157 @@ class VmManager: "entries": redacted_entries, } + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + with self._lock: + workspace = self._load_workspace_locked(workspace_id) + self._ensure_workspace_not_expired_locked(workspace, time.time()) + self._refresh_workspace_liveness_locked(workspace) + self._refresh_workspace_service_counts_locked(workspace) + self._save_workspace_locked(workspace) + + command_entries = self._read_workspace_logs_locked(workspace.workspace_id) + recent_commands = [ + { + "sequence": int(entry["sequence"]), + "command": str(entry["command"]), + "cwd": str(entry["cwd"]), + "exit_code": int(entry["exit_code"]), + "duration_ms": int(entry["duration_ms"]), + "execution_mode": str(entry["execution_mode"]), + "recorded_at": float(entry["recorded_at"]), + } + for entry in command_entries[-5:] + ] + recent_commands.reverse() + + review_events = self._list_workspace_review_events_locked(workspace.workspace_id) + + def _recent_events( + kinds: set[WorkspaceReviewEventKind], + *, + limit: int = 5, + ) -> list[dict[str, Any]]: + matched = [ + { + "event_kind": event.event_kind, + "recorded_at": event.recorded_at, + **event.payload, + } + for event in review_events + if event.event_kind in kinds + ] + matched = matched[-limit:] + matched.reverse() + return matched + + current_services = [ + self._serialize_workspace_service(service) + for service in self._list_workspace_services_locked(workspace.workspace_id) + ] + current_services.sort(key=lambda item: str(item["service_name"])) + try: + snapshots = self._list_workspace_snapshots_locked(workspace) + named_snapshot_count = max(len(snapshots) - 1, 0) + except RuntimeError: + named_snapshot_count = 0 + + service_count = len(current_services) + running_service_count = sum( + 1 for service in current_services if service["state"] == "running" + ) + execution_mode = str(workspace.metadata.get("execution_mode", "pending")) + payload: dict[str, Any] = { + "workspace_id": workspace.workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": workspace.state, + "workspace_path": WORKSPACE_GUEST_PATH, + "execution_mode": execution_mode, + "last_activity_at": workspace.last_activity_at, + "session_started_at": ( + workspace.last_reset_at + if workspace.last_reset_at is not None + else workspace.created_at + ), + "outcome": { + "command_count": workspace.command_count, + "last_command": workspace.last_command, + "service_count": service_count, + "running_service_count": running_service_count, + "export_count": sum( + 1 for event in review_events if event.event_kind == "workspace_export" + ), + "snapshot_count": named_snapshot_count, + "reset_count": workspace.reset_count, + }, + "commands": { + "total": workspace.command_count, + "recent": recent_commands, + }, + "edits": { + "recent": _recent_events({"sync_push", "file_write", "patch_apply"}), + }, + "services": { + "current": current_services, + "recent": _recent_events({"service_start", "service_stop"}), + }, + "artifacts": { + "exports": _recent_events({"workspace_export"}), + }, + "snapshots": { + "named_count": named_snapshot_count, + "recent": _recent_events({"snapshot_create", "snapshot_delete"}), + }, + } + + if payload["state"] != "started": + payload["changes"] = { + "available": False, + "reason": ( + f"workspace {workspace_id!r} must be in 'started' state before " + "workspace_summary can compute current changes" + ), + "changed": False, + "summary": None, + "entries": [], + } + return payload + + try: + diff_payload = self.diff_workspace(workspace_id) + except Exception as exc: + payload["changes"] = { + "available": False, + "reason": str(exc), + "changed": False, + "summary": None, + "entries": [], + } + return payload + + diff_entries: list[dict[str, Any]] = [] + raw_entries = diff_payload.get("entries") + if isinstance(raw_entries, list): + for entry in raw_entries[:10]: + if not isinstance(entry, dict): + continue + diff_entries.append( + { + key: value + for key, value in entry.items() + if key != "text_patch" + } + ) + payload["changes"] = { + "available": True, + "reason": None, + "changed": bool(diff_payload.get("changed", False)), + "summary": diff_payload.get("summary"), + "entries": diff_entries, + } + return payload + def stop_workspace(self, workspace_id: str) -> dict[str, Any]: with self._lock: workspace = self._load_workspace_locked(workspace_id) @@ -5795,6 +6092,9 @@ class VmManager: def _workspace_commands_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_COMMANDS_DIRNAME + def _workspace_review_dir(self, workspace_id: str) -> Path: + return self._workspace_dir(workspace_id) / WORKSPACE_REVIEW_DIRNAME + def _workspace_shells_dir(self, workspace_id: str) -> Path: return self._workspace_dir(workspace_id) / WORKSPACE_SHELLS_DIRNAME @@ -5810,6 +6110,9 @@ class VmManager: def _workspace_shell_record_path(self, workspace_id: str, shell_id: str) -> Path: return self._workspace_shells_dir(workspace_id) / f"{shell_id}.json" + def _workspace_review_record_path(self, workspace_id: str, event_id: str) -> Path: + return self._workspace_review_dir(workspace_id) / f"{event_id}.json" + def _workspace_service_record_path(self, workspace_id: str, service_name: str) -> Path: return self._workspace_services_dir(workspace_id) / f"{service_name}.json" @@ -6004,6 +6307,46 @@ class VmManager: entries.append(entry) return entries + def _record_workspace_review_event_locked( + self, + workspace_id: str, + *, + event_kind: WorkspaceReviewEventKind, + payload: dict[str, Any], + when: float | None = None, + ) -> WorkspaceReviewEventRecord: + recorded_at = time.time() if when is None else when + event = WorkspaceReviewEventRecord( + workspace_id=workspace_id, + event_kind=event_kind, + recorded_at=recorded_at, + payload=dict(payload), + ) + event_id = f"{int(recorded_at * 1_000_000_000):020d}-{uuid.uuid4().hex[:8]}" + record_path = self._workspace_review_record_path(workspace_id, event_id) + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text( + json.dumps(event.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + return event + + def _list_workspace_review_events_locked( + self, + workspace_id: str, + ) -> list[WorkspaceReviewEventRecord]: + review_dir = self._workspace_review_dir(workspace_id) + if not review_dir.exists(): + return [] + events: list[WorkspaceReviewEventRecord] = [] + for record_path in sorted(review_dir.glob("*.json")): + payload = json.loads(record_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + continue + events.append(WorkspaceReviewEventRecord.from_payload(payload)) + events.sort(key=lambda item: (item.recorded_at, item.event_kind)) + return events + def _workspace_instance_for_live_shell_locked(self, workspace: WorkspaceRecord) -> VmInstance: instance = self._workspace_instance_for_live_operation_locked( workspace, @@ -6360,10 +6703,12 @@ class VmManager: shutil.rmtree(self._workspace_runtime_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_host_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_commands_dir(workspace_id), ignore_errors=True) + shutil.rmtree(self._workspace_review_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_shells_dir(workspace_id), ignore_errors=True) shutil.rmtree(self._workspace_services_dir(workspace_id), ignore_errors=True) self._workspace_host_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_commands_dir(workspace_id).mkdir(parents=True, exist_ok=True) + self._workspace_review_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_shells_dir(workspace_id).mkdir(parents=True, exist_ok=True) self._workspace_services_dir(workspace_id).mkdir(parents=True, exist_ok=True) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 40e0ed9..26635d9 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -457,6 +457,11 @@ def _scenario_review_eval(pyro: Pyro, *, root: Path, environment: str) -> None: assert int(rerun["exit_code"]) == 0, rerun pyro.export_workspace(workspace_id, "review-report.txt", output_path=export_path) assert export_path.read_text(encoding="utf-8") == "review=pass\n" + summary = pyro.summarize_workspace(workspace_id) + assert summary["workspace_id"] == workspace_id, summary + assert summary["changes"]["available"] is True, summary + assert summary["artifacts"]["exports"], summary + assert summary["snapshots"]["named_count"] >= 1, summary finally: if shell_id is not None and workspace_id is not None: try: diff --git a/tests/test_api.py b/tests/test_api.py index 538e419..2fb7d10 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -455,6 +455,7 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: services = pyro.list_services(workspace_id) service_status = pyro.status_service(workspace_id, "app") service_logs = pyro.logs_service(workspace_id, "app", all=True) + summary = pyro.summarize_workspace(workspace_id) reset = pyro.reset_workspace(workspace_id, snapshot="checkpoint") deleted_snapshot = pyro.delete_snapshot(workspace_id, "checkpoint") status = pyro.status_workspace(workspace_id) @@ -491,6 +492,9 @@ def test_pyro_workspace_methods_delegate_to_manager(tmp_path: Path) -> None: assert service_status["state"] == "running" assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None + assert summary["workspace_id"] == workspace_id + assert summary["commands"]["total"] >= 1 + assert summary["changes"]["available"] is True assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["secrets"] == created["secrets"] assert deleted_snapshot["deleted"] is True @@ -1054,6 +1058,14 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non calls.append(("logs_workspace", {"workspace_id": workspace_id})) return {"workspace_id": workspace_id, "count": 0, "entries": []} + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + calls.append(("summarize_workspace", {"workspace_id": workspace_id})) + return { + "workspace_id": workspace_id, + "state": "started", + "changes": {"available": True, "changed": False, "summary": None, "entries": []}, + } + def open_shell( self, workspace_id: str, @@ -1185,6 +1197,9 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non status = _extract_structured( await server.call_tool("workspace_status", {"workspace_id": "workspace-123"}) ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": "workspace-123"}) + ) logs = _extract_structured( await server.call_tool("workspace_logs", {"workspace_id": "workspace-123"}) ) @@ -1286,6 +1301,7 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non ) return ( status, + summary, logs, opened, read, @@ -1300,13 +1316,15 @@ def test_pyro_create_server_workspace_status_shell_and_service_delegate() -> Non results = asyncio.run(_run()) assert results[0]["state"] == "started" - assert results[1]["count"] == 0 - assert results[2]["shell_id"] == "shell-1" - assert results[6]["closed"] is True - assert results[7]["state"] == "running" - assert results[10]["state"] == "running" + assert results[1]["workspace_id"] == "workspace-123" + assert results[2]["count"] == 0 + assert results[3]["shell_id"] == "shell-1" + assert results[7]["closed"] is True + assert results[8]["state"] == "running" + assert results[11]["state"] == "running" assert calls == [ ("status_workspace", {"workspace_id": "workspace-123"}), + ("summarize_workspace", {"workspace_id": "workspace-123"}), ("logs_workspace", {"workspace_id": "workspace-123"}), ( "open_shell", diff --git a/tests/test_cli.py b/tests/test_cli.py index b55e752..2d15c18 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -39,6 +39,7 @@ def test_cli_help_guides_first_run() -> None: assert "pyro host print-config opencode" in help_text assert "If you want terminal-level visibility into the workspace model:" in help_text assert "pyro workspace exec WORKSPACE_ID -- cat note.txt" in help_text + assert "pyro workspace summary WORKSPACE_ID" in help_text assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in help_text assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in help_text assert "pyro workspace sync push WORKSPACE_ID ./changes" in help_text @@ -127,6 +128,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro workspace start WORKSPACE_ID" in workspace_help assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" in workspace_help assert "pyro workspace reset WORKSPACE_ID --snapshot checkpoint" in workspace_help + assert "pyro workspace summary WORKSPACE_ID" in workspace_help assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help workspace_create_help = _subparser_choice( @@ -181,6 +183,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "--label" in workspace_update_help assert "--clear-label" in workspace_update_help + workspace_summary_help = _subparser_choice( + _subparser_choice(parser, "workspace"), "summary" + ).format_help() + assert "Summarize the current workspace session since the last reset" in workspace_summary_help + assert "pyro workspace summary WORKSPACE_ID" in workspace_summary_help + workspace_file_help = _subparser_choice( _subparser_choice(parser, "workspace"), "file" ).format_help() @@ -2515,6 +2523,170 @@ def test_cli_workspace_logs_prints_json( assert payload["count"] == 0 +def test_cli_workspace_summary_prints_json( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "name": "review-eval", + "labels": {"suite": "smoke"}, + "environment": "debian:12", + "state": "started", + "last_activity_at": 2.0, + "session_started_at": 1.0, + "outcome": { + "command_count": 1, + "last_command": {"command": "cat note.txt", "exit_code": 0}, + "service_count": 0, + "running_service_count": 0, + "export_count": 1, + "snapshot_count": 1, + "reset_count": 0, + }, + "commands": {"total": 1, "recent": []}, + "edits": {"recent": []}, + "changes": {"available": True, "changed": False, "summary": None, "entries": []}, + "services": {"current": [], "recent": []}, + "artifacts": {"exports": []}, + "snapshots": {"named_count": 1, "recent": []}, + } + + class SummaryParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="summary", + workspace_id="workspace-123", + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["workspace_id"] == "workspace-123" + assert payload["outcome"]["export_count"] == 1 + + +def test_cli_workspace_summary_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + assert workspace_id == "workspace-123" + return { + "workspace_id": workspace_id, + "name": "review-eval", + "labels": {"suite": "smoke", "use_case": "review-eval"}, + "environment": "debian:12", + "state": "started", + "last_activity_at": 3.0, + "session_started_at": 1.0, + "outcome": { + "command_count": 2, + "last_command": {"command": "sh review.sh", "exit_code": 0}, + "service_count": 1, + "running_service_count": 0, + "export_count": 1, + "snapshot_count": 1, + "reset_count": 0, + }, + "commands": { + "total": 2, + "recent": [ + { + "sequence": 2, + "command": "sh review.sh", + "cwd": "/workspace", + "exit_code": 0, + "duration_ms": 12, + "execution_mode": "guest_vsock", + "recorded_at": 3.0, + } + ], + }, + "edits": { + "recent": [ + { + "event_kind": "patch_apply", + "recorded_at": 2.0, + "path": "/workspace/note.txt", + } + ] + }, + "changes": { + "available": True, + "changed": True, + "summary": { + "total": 1, + "added": 0, + "modified": 1, + "deleted": 0, + "type_changed": 0, + "text_patched": 1, + "non_text": 0, + }, + "entries": [ + { + "path": "/workspace/note.txt", + "status": "modified", + "artifact_type": "file", + } + ], + }, + "services": { + "current": [{"service_name": "app", "state": "stopped"}], + "recent": [ + { + "event_kind": "service_stop", + "service_name": "app", + "state": "stopped", + } + ], + }, + "artifacts": { + "exports": [ + { + "workspace_path": "review-report.txt", + "output_path": "/tmp/review-report.txt", + } + ] + }, + "snapshots": { + "named_count": 1, + "recent": [ + {"event_kind": "snapshot_create", "snapshot_name": "checkpoint"} + ], + }, + } + + class SummaryParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="summary", + workspace_id="workspace-123", + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SummaryParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Workspace review: workspace-123" in output + assert "Outcome: commands=2 services=0/1 exports=1 snapshots=1 resets=0" in output + assert "Recent commands:" in output + assert "Recent edits:" in output + assert "Changes: total=1 added=0 modified=1 deleted=0 type_changed=0 non_text=0" in output + assert "Recent exports:" in output + assert "Recent snapshot events:" in output + + def test_cli_workspace_delete_prints_human( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -3028,6 +3200,16 @@ def test_content_only_read_docs_are_aligned() -> None: assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run +def test_workspace_summary_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + + assert 'workspace summary "$WORKSPACE_ID"' in readme + assert 'workspace summary "$WORKSPACE_ID"' in install + assert 'workspace summary "$WORKSPACE_ID"' in first_run + + def test_cli_workspace_shell_write_signal_close_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 35aad8f..527c68b 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -58,6 +58,7 @@ from pyro_mcp.contract import ( PUBLIC_CLI_WORKSPACE_START_FLAGS, PUBLIC_CLI_WORKSPACE_STOP_FLAGS, PUBLIC_CLI_WORKSPACE_SUBCOMMANDS, + PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, @@ -272,6 +273,11 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_WORKSPACE_STOP_FLAGS: assert flag in workspace_stop_help_text + workspace_summary_help_text = _subparser_choice( + _subparser_choice(parser, "workspace"), "summary" + ).format_help() + for flag in PUBLIC_CLI_WORKSPACE_SUMMARY_FLAGS: + assert flag in workspace_summary_help_text workspace_shell_help_text = _subparser_choice( _subparser_choice(parser, "workspace"), "shell", diff --git a/tests/test_server.py b/tests/test_server.py index e52c2d0..1017d77 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -602,6 +602,9 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: }, ) ) + summary = _extract_structured( + await server.call_tool("workspace_summary", {"workspace_id": workspace_id}) + ) reset = _extract_structured( await server.call_tool( "workspace_reset", @@ -639,6 +642,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, + summary, reset, deleted_snapshot, logs, @@ -664,6 +668,7 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: service_status, service_logs, service_stopped, + summary, reset, deleted_snapshot, logs, @@ -700,6 +705,10 @@ def test_workspace_tools_round_trip(tmp_path: Path) -> None: assert service_logs["stderr"].count("[REDACTED]") >= 1 assert service_logs["tail_lines"] is None assert service_stopped["state"] == "stopped" + assert summary["workspace_id"] == created["workspace_id"] + assert summary["commands"]["total"] >= 1 + assert summary["changes"]["available"] is True + assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/subdir/more.txt" assert reset["workspace_reset"]["snapshot_name"] == "checkpoint" assert reset["secrets"] == created["secrets"] assert reset["command_count"] == 0 diff --git a/tests/test_vm_manager.py b/tests/test_vm_manager.py index 22e953c..73fc74f 100644 --- a/tests/test_vm_manager.py +++ b/tests/test_vm_manager.py @@ -699,6 +699,124 @@ def test_workspace_diff_and_export_round_trip(tmp_path: Path) -> None: assert logs["count"] == 0 +def test_workspace_summary_synthesizes_current_session(tmp_path: Path) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + update_dir = tmp_path / "update" + update_dir.mkdir() + (update_dir / "more.txt").write_text("more\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + name="review-eval", + labels={"suite": "smoke"}, + )["workspace_id"] + ) + manager.push_workspace_sync(workspace_id, source_path=update_dir) + manager.write_workspace_file(workspace_id, "src/app.py", text="print('hello')\n") + manager.apply_workspace_patch( + workspace_id, + patch=( + "--- a/note.txt\n" + "+++ b/note.txt\n" + "@@ -1 +1 @@\n" + "-hello\n" + "+patched\n" + ), + ) + manager.exec_workspace(workspace_id, command="cat note.txt", timeout_seconds=30) + manager.create_snapshot(workspace_id, "checkpoint") + export_path = tmp_path / "exported-note.txt" + manager.export_workspace(workspace_id, "note.txt", output_path=export_path) + manager.start_service( + workspace_id, + "app", + command='sh -lc \'trap "exit 0" TERM; touch .ready; while true; do sleep 60; done\'', + readiness={"type": "file", "path": ".ready"}, + ) + manager.stop_service(workspace_id, "app") + + summary = manager.summarize_workspace(workspace_id) + + assert summary["workspace_id"] == workspace_id + assert summary["name"] == "review-eval" + assert summary["labels"] == {"suite": "smoke"} + assert summary["outcome"]["command_count"] == 1 + assert summary["outcome"]["export_count"] == 1 + assert summary["outcome"]["snapshot_count"] == 1 + assert summary["commands"]["total"] == 1 + assert summary["commands"]["recent"][0]["command"] == "cat note.txt" + assert [event["event_kind"] for event in summary["edits"]["recent"]] == [ + "patch_apply", + "file_write", + "sync_push", + ] + assert summary["changes"]["available"] is True + assert summary["changes"]["changed"] is True + assert summary["changes"]["summary"]["total"] == 4 + assert summary["services"]["current"][0]["service_name"] == "app" + assert [event["event_kind"] for event in summary["services"]["recent"]] == [ + "service_stop", + "service_start", + ] + assert summary["artifacts"]["exports"][0]["workspace_path"] == "/workspace/note.txt" + assert summary["snapshots"]["named_count"] == 1 + assert summary["snapshots"]["recent"][0]["snapshot_name"] == "checkpoint" + + +def test_workspace_summary_degrades_gracefully_for_stopped_and_legacy_workspaces( + tmp_path: Path, +) -> None: + seed_dir = tmp_path / "seed" + seed_dir.mkdir() + (seed_dir / "note.txt").write_text("hello\n", encoding="utf-8") + + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + stopped_workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + manager.exec_workspace(stopped_workspace_id, command="cat note.txt", timeout_seconds=30) + manager.stop_workspace(stopped_workspace_id) + stopped_summary = manager.summarize_workspace(stopped_workspace_id) + assert stopped_summary["commands"]["total"] == 1 + assert stopped_summary["changes"]["available"] is False + assert "must be in 'started' state" in str(stopped_summary["changes"]["reason"]) + + legacy_workspace_id = str( + manager.create_workspace( + environment="debian:12-base", + allow_host_compat=True, + seed_path=seed_dir, + )["workspace_id"] + ) + baseline_path = ( + tmp_path / "vms" / "workspaces" / legacy_workspace_id / "baseline" / "workspace.tar" + ) + baseline_path.unlink() + legacy_summary = manager.summarize_workspace(legacy_workspace_id) + assert legacy_summary["changes"]["available"] is False + assert "baseline snapshot" in str(legacy_summary["changes"]["reason"]) + + def test_workspace_file_ops_and_patch_round_trip(tmp_path: Path) -> None: seed_dir = tmp_path / "seed" seed_dir.mkdir() diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index b02fd10..8297c22 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -391,6 +391,69 @@ class _FakePyro: "workspace_reset": {"snapshot_name": snapshot}, } + def summarize_workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self._resolve_workspace(workspace_id) + changed = self._diff_changed(workspace) + return { + "workspace_id": workspace_id, + "name": workspace.name, + "labels": dict(workspace.labels), + "environment": workspace.environment, + "state": "started", + "last_activity_at": workspace.last_activity_at, + "session_started_at": workspace.created_at, + "outcome": { + "command_count": 0, + "last_command": None, + "service_count": len(workspace.services), + "running_service_count": sum( + 1 + for service in workspace.services.values() + if service["state"] == "running" + ), + "export_count": 1, + "snapshot_count": max(len(workspace.snapshots) - 1, 0), + "reset_count": workspace.reset_count, + }, + "commands": {"total": 0, "recent": []}, + "edits": {"recent": []}, + "changes": { + "available": True, + "reason": None, + "changed": changed, + "summary": {"total": 1 if changed else 0}, + "entries": ( + [ + { + "path": "/workspace/artifact.txt", + "status": "modified", + "artifact_type": "file", + } + ] + if changed + else [] + ), + }, + "services": { + "current": [ + {"service_name": name, "state": service["state"]} + for name, service in sorted(workspace.services.items()) + ], + "recent": [], + }, + "artifacts": { + "exports": [ + { + "workspace_path": "review-report.txt", + "output_path": str( + self._workspace_dir(workspace_id) / "exported-review.txt" + ), + } + ] + }, + "snapshots": {"named_count": max(len(workspace.snapshots) - 1, 0), "recent": []}, + } + def open_shell(self, workspace_id: str, **_: Any) -> dict[str, Any]: workspace = self._resolve_workspace(workspace_id) self._shell_counter += 1 diff --git a/uv.lock b/uv.lock index cc18ce9..5944d05 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.2.0" +version = "4.3.0" source = { editable = "." } dependencies = [ { name = "mcp" }, From d0cf6d8f2182e5a8f7d3fb9c3d9516e0fed1d802 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 20:00:35 -0300 Subject: [PATCH 6/8] Add opinionated MCP modes for workspace workflows Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime. Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers. Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox. --- CHANGELOG.md | 11 + README.md | 58 ++- docs/first-run.md | 41 +- docs/install.md | 47 +- docs/integrations.md | 56 ++- docs/public-contract.md | 37 +- docs/roadmap/llm-chat-ergonomics.md | 8 +- .../4.4.0-opinionated-use-case-modes.md | 2 +- docs/use-cases/README.md | 12 +- docs/use-cases/cold-start-repo-validation.md | 12 +- docs/use-cases/parallel-workspaces.md | 11 +- docs/use-cases/repro-fix-loop.md | 13 +- docs/use-cases/review-eval-workflows.md | 8 +- docs/use-cases/untrusted-inspection.md | 8 +- examples/claude_code_mcp.md | 20 +- examples/codex_mcp.md | 20 +- examples/mcp_client_config.md | 38 +- examples/opencode_mcp_config.json | 2 +- pyproject.toml | 2 +- src/pyro_mcp/api.py | 455 ++++++++++++++---- src/pyro_mcp/cli.py | 60 ++- src/pyro_mcp/contract.py | 76 +++ src/pyro_mcp/host_helpers.py | 13 +- src/pyro_mcp/server.py | 7 +- src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/workspace_use_case_smokes.py | 27 +- tests/test_api.py | 120 +++++ tests/test_cli.py | 63 ++- tests/test_host_helpers.py | 17 + tests/test_public_contract.py | 15 + tests/test_server.py | 32 ++ tests/test_workspace_use_case_smokes.py | 11 +- uv.lock | 2 +- 33 files changed, 1034 insertions(+), 274 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa1330..982d57d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.4.0 + +- Added explicit named MCP/server modes for the main workspace workflows: + `repro-fix`, `inspect`, `cold-start`, and `review-eval`. +- Kept the generic no-mode `workspace-core` path available as the escape hatch, + while making named modes the first user-facing story across help text, host + helpers, and the recipe docs. +- Aligned the shared use-case smoke runner with those modes so the repro/fix + and cold-start flows now prove a mode-backed happy path instead of only the + generic profile path. + ## 4.3.0 - Added `pyro workspace summary`, `Pyro.summarize_workspace()`, and MCP diff --git a/README.md b/README.md index 74f0965..f433117 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.3.0: [CHANGELOG.md#430](CHANGELOG.md#430) +- What's new in 4.4.0: [CHANGELOG.md#440](CHANGELOG.md#440) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -76,7 +76,7 @@ What success looks like: ```bash Platform: linux-x86_64 Runtime: PASS -Catalog version: 4.3.0 +Catalog version: 4.4.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -95,13 +95,15 @@ for the guest image. ## Chat Host Quickstart -After the quickstart works, the intended next step is to connect a chat host. -Use the helper flow first: +After the quickstart works, the intended next step is to connect a chat host in +one named mode. 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 +uvx --from pyro-mcp pyro host connect codex --mode repro-fix +uvx --from pyro-mcp pyro host connect codex --mode inspect +uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix ``` If setup drifts or you want to inspect it first: @@ -113,15 +115,28 @@ 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`. +Those helpers wrap the same `pyro mcp serve` entrypoint. Use a named mode when +one workflow already matches the job. Fall back to the generic no-mode path +when the mode feels too narrow. + +Mode examples: + +```bash +uvx --from pyro-mcp pyro mcp serve --mode repro-fix +uvx --from pyro-mcp pyro mcp serve --mode inspect +uvx --from pyro-mcp pyro mcp serve --mode cold-start +uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Generic escape hatch: ```bash uvx --from pyro-mcp pyro mcp serve ``` -If the host does not preserve the server working directory, use: +From a repo root, the generic path auto-detects the current Git checkout and +lets the first `workspace_create` omit `seed_path`. If the host does not +preserve the server working directory, use: ```bash uvx --from pyro-mcp pyro host connect codex --project-path /abs/path/to/repo @@ -142,16 +157,16 @@ Copy-paste host-specific starts: - OpenCode: [examples/opencode_mcp_config.json](examples/opencode_mcp_config.json) - Generic MCP config: [examples/mcp_client_config.md](examples/mcp_client_config.md) -Claude Code: +Claude Code cold-start or review-eval: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start ``` -Codex: +Codex repro-fix or inspect: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix ``` OpenCode `opencode.json` snippet: @@ -162,7 +177,7 @@ OpenCode `opencode.json` snippet: "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } @@ -176,18 +191,19 @@ array. If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with `pyro` in the same command or config shape. -Use `--profile workspace-full` only when the chat truly needs shells, services, +Use the generic no-mode path when the named mode feels too narrow. Move to +`--profile workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools. ## Zero To Hero 1. Validate the host with `pyro doctor`. 2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. -3. Connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or - `pyro host print-config opencode`, then fall back to raw `pyro mcp serve` - with `--project-path` / `--repo-url` when cwd is not the source of truth. +3. Connect Claude Code, Codex, or OpenCode with one named mode such as + `pyro host connect codex --mode repro-fix`, then fall back to raw + `pyro mcp serve --mode ...` or the generic no-mode path when needed. 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` is the shortest chat-first mode and story. 5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised workflows. diff --git a/docs/first-run.md b/docs/first-run.md index 9e26999..894067c 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -27,7 +27,7 @@ Networking: tun=yes ip_forward=yes ```bash $ uvx --from pyro-mcp pyro env list -Catalog version: 4.3.0 +Catalog version: 4.4.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -71,7 +71,17 @@ streams, so they may appear in either order in terminals or capture tools. Use ## 5. Start the MCP server -Bare `pyro mcp serve` now starts `workspace-core`. From a repo root, it also +Use a named mode when one workflow already matches the job: + +```bash +$ uvx --from pyro-mcp pyro mcp serve --mode repro-fix +$ uvx --from pyro-mcp pyro mcp serve --mode inspect +$ uvx --from pyro-mcp pyro mcp serve --mode cold-start +$ uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path when the mode feels too narrow. Bare +`pyro mcp serve` still starts `workspace-core`. From a repo root, it also auto-detects the current Git checkout so the first `workspace_create` can omit `seed_path`: @@ -96,9 +106,11 @@ $ uvx --from pyro-mcp pyro mcp serve --repo-url https://github.com/example/proje 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 +$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix +$ uvx --from pyro-mcp pyro host connect codex --mode inspect +$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +$ uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +$ uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix ``` If setup drifts later: @@ -110,19 +122,19 @@ $ uvx --from pyro-mcp pyro host repair codex $ uvx --from pyro-mcp pyro host repair opencode ``` -Claude Code: +Claude Code cold-start or review-eval: ```bash -$ uvx --from pyro-mcp pyro host connect claude-code -$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +$ uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +$ claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start $ claude mcp list ``` -Codex: +Codex repro-fix or inspect: ```bash -$ uvx --from pyro-mcp pyro host connect codex -$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +$ uvx --from pyro-mcp pyro host connect codex --mode repro-fix +$ codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix $ codex mcp list ``` @@ -141,7 +153,7 @@ Other host-specific references: Once the host is connected, move to one of the five recipe docs in [use-cases/README.md](use-cases/README.md). -The shortest chat-first story is: +The shortest chat-first mode and story is: - [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) @@ -162,8 +174,9 @@ $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./ $ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID" ``` -Move to `--profile workspace-full` only when the chat really needs shells, -services, snapshots, secrets, network policy, or disk tools. +Move to the generic no-mode path when the named mode is too narrow. Move to +`--profile workspace-full` only when the chat really needs shells, services, +snapshots, secrets, network policy, or disk tools. ## 8. Trust the smoke pack diff --git a/docs/install.md b/docs/install.md index 4a57178..ed03f26 100644 --- a/docs/install.md +++ b/docs/install.md @@ -62,8 +62,8 @@ pyro run debian:12 -- git --version If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. -After that one-shot proof works, the intended next step is `pyro host connect` -or `pyro host print-config`. +After that one-shot proof works, the intended next step is a named chat mode +through `pyro host connect` or `pyro host print-config`. ## 1. Check the host @@ -93,7 +93,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 4.3.0 +Catalog version: 4.4.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -144,9 +144,11 @@ deterministic structured result. 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 +uvx --from pyro-mcp pyro host connect codex --mode repro-fix +uvx --from pyro-mcp pyro host connect codex --mode inspect +uvx --from pyro-mcp pyro host connect claude-code --mode cold-start +uvx --from pyro-mcp pyro host connect claude-code --mode review-eval +uvx --from pyro-mcp pyro host print-config opencode --mode repro-fix ``` If setup drifts later, inspect and repair it with: @@ -158,7 +160,17 @@ 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 +Use a named mode when one workflow already matches the job: + +```bash +uvx --from pyro-mcp pyro mcp serve --mode repro-fix +uvx --from pyro-mcp pyro mcp serve --mode inspect +uvx --from pyro-mcp pyro mcp serve --mode cold-start +uvx --from pyro-mcp pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path when the mode feels too narrow. Bare +`pyro mcp serve` still starts `workspace-core`. From a repo root, it also auto-detects the current Git checkout so the first `workspace_create` can omit `seed_path`. @@ -185,18 +197,18 @@ Copy-paste host-specific starts: - OpenCode config: [opencode_mcp_config.json](../examples/opencode_mcp_config.json) - Generic MCP fallback: [mcp_client_config.md](../examples/mcp_client_config.md) -Claude Code: +Claude Code cold-start or review-eval: ```bash -pyro host connect claude-code -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +pyro host connect claude-code --mode cold-start +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start ``` -Codex: +Codex repro-fix or inspect: ```bash -pyro host connect codex -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +pyro host connect codex --mode repro-fix +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix ``` OpenCode uses the `mcp` / `type: "local"` config shape shown in @@ -205,7 +217,8 @@ OpenCode uses the `mcp` / `type: "local"` config shape shown in If `pyro-mcp` is already installed, replace `uvx --from pyro-mcp pyro` with `pyro` in the same command or config shape. -Use `--profile workspace-full` only when the chat truly needs shells, services, +Use the generic no-mode path when the named mode is too narrow. Move to +`--profile workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools. ## 6. Go from zero to hero @@ -215,9 +228,9 @@ The intended user journey is: 1. validate the host with `pyro doctor` 2. pull `debian:12` 3. prove guest execution with `pyro run debian:12 -- git --version` -4. connect Claude Code, Codex, or OpenCode with `pyro host connect ...` or - `pyro host print-config opencode`, then use raw `pyro mcp serve` only when - you need `--project-path` / `--repo-url` +4. connect Claude Code, Codex, or OpenCode with one named mode such as + `pyro host connect codex --mode repro-fix`, then use raw + `pyro mcp serve --mode ...` or the generic no-mode path when needed 5. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) 6. trust but verify with `make smoke-use-cases` diff --git a/docs/integrations.md b/docs/integrations.md index 933a5ea..1abf5a9 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -13,7 +13,29 @@ path is still being shaped. Use this page after you have already validated the host and guest execution through [install.md](install.md) or [first-run.md](first-run.md). -## Recommended Default +## Recommended Modes + +Use a named mode when one workflow already matches the job: + +```bash +pyro host connect codex --mode repro-fix +pyro host connect codex --mode inspect +pyro host connect claude-code --mode cold-start +pyro host connect claude-code --mode review-eval +``` + +The mode-backed raw server forms are: + +```bash +pyro mcp serve --mode repro-fix +pyro mcp serve --mode inspect +pyro mcp serve --mode cold-start +pyro mcp serve --mode review-eval +``` + +Use the generic no-mode path only when the named mode feels too narrow. + +## Generic Default Bare `pyro mcp serve` starts `workspace-core`. From a repo root, it also auto-detects the current Git checkout so the first `workspace_create` can omit @@ -43,22 +65,25 @@ snapshots, secrets, network policy, or disk tools. 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 connect codex --mode repro-fix +pyro host connect codex --mode inspect +pyro host connect claude-code --mode cold-start +pyro host connect claude-code --mode review-eval +pyro host print-config opencode --mode repro-fix 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. +These helpers wrap the same `pyro mcp serve` entrypoint, make named modes the +first user-facing story, and still leave the generic no-mode path available +when a mode is too narrow. ## Claude Code Preferred: ```bash -pyro host connect claude-code +pyro host connect claude-code --mode cold-start ``` Repair: @@ -70,14 +95,14 @@ pyro host repair claude-code Package without install: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start claude mcp list ``` If Claude Code launches the server from an unexpected cwd, use: ```bash -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 --mode cold-start --project-path /abs/path/to/repo ``` Already installed: @@ -96,7 +121,7 @@ Reference: Preferred: ```bash -pyro host connect codex +pyro host connect codex --mode repro-fix ``` Repair: @@ -108,14 +133,14 @@ pyro host repair codex Package without install: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix codex mcp list ``` If Codex launches the server from an unexpected cwd, use: ```bash -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 --mode repro-fix --project-path /abs/path/to/repo ``` Already installed: @@ -150,7 +175,7 @@ Minimal `opencode.json` snippet: "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } @@ -165,8 +190,9 @@ array. ## Generic MCP Fallback -Use this only when the host expects a plain `mcpServers` JSON config and does -not already have a dedicated example in the repo: +Use this only when the host expects a plain `mcpServers` JSON config, when the +named modes are too narrow, and when it does not already have a dedicated +example in the repo: - [mcp_client_config.md](../examples/mcp_client_config.md) diff --git a/docs/public-contract.md b/docs/public-contract.md index e1e0856..3d2265b 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -75,10 +75,15 @@ pyro mcp serve What to expect: -- bare `pyro mcp serve` starts `workspace-core` +- named modes are now the first chat-host story: + - `pyro mcp serve --mode repro-fix` + - `pyro mcp serve --mode inspect` + - `pyro mcp serve --mode cold-start` + - `pyro mcp serve --mode review-eval` +- bare `pyro mcp serve` remains the generic no-mode path and starts + `workspace-core` - from a repo root, bare `pyro mcp serve` also auto-detects the current Git checkout so `workspace_create` can omit `seed_path` -- `workspace-core` is the default product path for chat hosts - `pyro mcp serve --profile workspace-full` explicitly opts into the larger tool surface - `pyro mcp serve --profile vm-run` exposes the smallest one-shot-only surface @@ -105,7 +110,21 @@ The chat-host bootstrap helper surface is: These helpers wrap the same `pyro mcp serve` entrypoint and are the preferred setup and repair path for supported hosts. -## Chat-Facing Workspace Contract +## Named Modes + +The supported named modes are: + +| Mode | Intended workflow | Key tools | +| --- | --- | --- | +| `repro-fix` | reproduce, patch, rerun, diff, export, reset | file ops, patch, diff, export, reset, summary | +| `inspect` | inspect suspicious or unfamiliar code with the smallest persistent surface | file list/read, exec, export, summary | +| `cold-start` | validate a fresh repo and keep services alive long enough to prove readiness | exec, export, reset, summary, service tools | +| `review-eval` | interactive review, checkpointing, shell-driven evaluation, and export | shell tools, snapshot tools, diff/export, summary | + +Use the generic no-mode path when one of those named modes feels too narrow for +the job. + +## Generic Workspace Contract `workspace-core` is the normal chat path. It exposes: @@ -151,13 +170,13 @@ Move to `workspace-full` only when the chat truly needs: The documented product workflows are: -| Workflow | Recommended profile | Doc | +| Workflow | Recommended mode | Doc | | --- | --- | --- | -| Cold-start repo validation | `workspace-full` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) | -| Repro plus fix loop | `workspace-core` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) | -| Parallel isolated workspaces | `workspace-core` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) | -| Unsafe or untrusted code inspection | `workspace-core` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) | -| Review and evaluation workflows | `workspace-full` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) | +| Cold-start repo validation | `cold-start` | [use-cases/cold-start-repo-validation.md](use-cases/cold-start-repo-validation.md) | +| Repro plus fix loop | `repro-fix` | [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md) | +| Parallel isolated workspaces | `repro-fix` | [use-cases/parallel-workspaces.md](use-cases/parallel-workspaces.md) | +| Unsafe or untrusted code inspection | `inspect` | [use-cases/untrusted-inspection.md](use-cases/untrusted-inspection.md) | +| Review and evaluation workflows | `review-eval` | [use-cases/review-eval-workflows.md](use-cases/review-eval-workflows.md) | Treat this smoke pack as the trustworthy guest-backed verification path for the advertised product: diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 60b85b6..be346fa 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.3.0`: +Current baseline is `4.4.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -82,7 +82,7 @@ capability gaps: 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) - Done 14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done -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) - Done 16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Planned Completed so far: @@ -123,10 +123,12 @@ Completed so far: - `4.3.0` adds a concise workspace review surface so users can inspect what the agent changed and ran since the last reset without reconstructing the session from several lower-level views by hand. +- `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`, + `inspect`, `cold-start`, or `review-eval` instead of choosing from the full + generic workspace surface first. Planned next: -- [`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) ## Expected Outcome diff --git a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md index c57f951..52d2d22 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md +++ b/docs/roadmap/llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md @@ -1,6 +1,6 @@ # `4.4.0` Opinionated Use-Case Modes -Status: Planned +Status: Done ## Goal diff --git a/docs/use-cases/README.md b/docs/use-cases/README.md index ec249b7..e471332 100644 --- a/docs/use-cases/README.md +++ b/docs/use-cases/README.md @@ -12,13 +12,13 @@ make smoke-use-cases Recipe matrix: -| Use case | Recommended profile | Smoke target | Recipe | +| Use case | Recommended mode | Smoke target | Recipe | | --- | --- | --- | --- | -| Cold-start repo validation | `workspace-full` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) | -| Repro plus fix loop | `workspace-core` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) | -| Parallel isolated workspaces | `workspace-core` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) | -| Unsafe or untrusted code inspection | `workspace-core` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) | -| Review and evaluation workflows | `workspace-full` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) | +| Cold-start repo validation | `cold-start` | `make smoke-cold-start-validation` | [cold-start-repo-validation.md](cold-start-repo-validation.md) | +| Repro plus fix loop | `repro-fix` | `make smoke-repro-fix-loop` | [repro-fix-loop.md](repro-fix-loop.md) | +| Parallel isolated workspaces | `repro-fix` | `make smoke-parallel-workspaces` | [parallel-workspaces.md](parallel-workspaces.md) | +| Unsafe or untrusted code inspection | `inspect` | `make smoke-untrusted-inspection` | [untrusted-inspection.md](untrusted-inspection.md) | +| Review and evaluation workflows | `review-eval` | `make smoke-review-eval` | [review-eval-workflows.md](review-eval-workflows.md) | All five recipes use the same real Firecracker-backed smoke runner: diff --git a/docs/use-cases/cold-start-repo-validation.md b/docs/use-cases/cold-start-repo-validation.md index 3b303be..763210b 100644 --- a/docs/use-cases/cold-start-repo-validation.md +++ b/docs/use-cases/cold-start-repo-validation.md @@ -1,6 +1,12 @@ # Cold-Start Repo Validation -Recommended profile: `workspace-full` +Recommended mode: `cold-start` + +Recommended startup: + +```bash +pyro host connect claude-code --mode cold-start +``` Smoke target: @@ -21,6 +27,10 @@ Chat-host recipe: 5. Export the validation report back to the host. 6. Delete the workspace when the evaluation is done. +If the named mode feels too narrow, fall back to the generic no-mode path and +then opt into `--profile workspace-full` only when you truly need the larger +advanced surface. + This recipe is intentionally guest-local and deterministic. It proves startup, service readiness, validation, and host-out report capture without depending on external networks or private registries. diff --git a/docs/use-cases/parallel-workspaces.md b/docs/use-cases/parallel-workspaces.md index ccabd7a..685f6a4 100644 --- a/docs/use-cases/parallel-workspaces.md +++ b/docs/use-cases/parallel-workspaces.md @@ -1,6 +1,12 @@ # Parallel Isolated Workspaces -Recommended profile: `workspace-core` +Recommended mode: `repro-fix` + +Recommended startup: + +```bash +pyro host connect codex --mode repro-fix +``` Smoke target: @@ -22,4 +28,5 @@ Chat-host recipe: The important proof here is operational, not syntactic: names, labels, list ordering, and file contents stay isolated even when multiple workspaces are -active at the same time. +active at the same time. Parallel work still means “open another workspace in +the same mode,” not “pick a special parallel-work mode.” diff --git a/docs/use-cases/repro-fix-loop.md b/docs/use-cases/repro-fix-loop.md index fac4090..ad920c5 100644 --- a/docs/use-cases/repro-fix-loop.md +++ b/docs/use-cases/repro-fix-loop.md @@ -1,6 +1,12 @@ # Repro Plus Fix Loop -Recommended profile: `workspace-core` +Recommended mode: `repro-fix` + +Recommended startup: + +```bash +pyro host connect codex --mode repro-fix +``` Smoke target: @@ -25,5 +31,8 @@ Chat-host recipe: 7. Diff and export the changed result. 8. Reset to baseline and delete the workspace. -This is the main `workspace-core` story: model-native file ops, repeatable exec, +If the mode feels too narrow for the job, fall back to the generic bare +`pyro mcp serve` path. + +This is the main `repro-fix` story: model-native file ops, repeatable exec, structured diff, explicit export, and reset-over-repair. diff --git a/docs/use-cases/review-eval-workflows.md b/docs/use-cases/review-eval-workflows.md index 3ab9204..4012c34 100644 --- a/docs/use-cases/review-eval-workflows.md +++ b/docs/use-cases/review-eval-workflows.md @@ -1,6 +1,12 @@ # Review And Evaluation Workflows -Recommended profile: `workspace-full` +Recommended mode: `review-eval` + +Recommended startup: + +```bash +pyro host connect claude-code --mode review-eval +``` Smoke target: diff --git a/docs/use-cases/untrusted-inspection.md b/docs/use-cases/untrusted-inspection.md index 6a7b85b..aab7ada 100644 --- a/docs/use-cases/untrusted-inspection.md +++ b/docs/use-cases/untrusted-inspection.md @@ -1,6 +1,12 @@ # Unsafe Or Untrusted Code Inspection -Recommended profile: `workspace-core` +Recommended mode: `inspect` + +Recommended startup: + +```bash +pyro host connect codex --mode inspect +``` Smoke target: diff --git a/examples/claude_code_mcp.md b/examples/claude_code_mcp.md index 0beb63c..de9931c 100644 --- a/examples/claude_code_mcp.md +++ b/examples/claude_code_mcp.md @@ -1,18 +1,22 @@ # Claude Code MCP Setup -Recommended profile: `workspace-core`. +Recommended modes: + +- `cold-start` +- `review-eval` Preferred helper flow: ```bash -pyro host connect claude-code -pyro host doctor +pyro host connect claude-code --mode cold-start +pyro host connect claude-code --mode review-eval +pyro host doctor --mode cold-start ``` Package without install: ```bash -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start claude mcp list ``` @@ -22,7 +26,7 @@ from the current checkout automatically. Already installed: ```bash -claude mcp add pyro -- pyro mcp serve +claude mcp add pyro -- pyro mcp serve --mode cold-start claude mcp list ``` @@ -30,14 +34,14 @@ If Claude Code launches the server from an unexpected cwd, pin the project explicitly: ```bash -pyro host connect claude-code --project-path /abs/path/to/repo -claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +pyro host connect claude-code --mode cold-start --project-path /abs/path/to/repo +claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start --project-path /abs/path/to/repo ``` If the local config drifts later: ```bash -pyro host repair claude-code +pyro host repair claude-code --mode cold-start ``` Move to `workspace-full` only when the chat truly needs shells, services, diff --git a/examples/codex_mcp.md b/examples/codex_mcp.md index 330f38e..53f3c7b 100644 --- a/examples/codex_mcp.md +++ b/examples/codex_mcp.md @@ -1,18 +1,22 @@ # Codex MCP Setup -Recommended profile: `workspace-core`. +Recommended modes: + +- `repro-fix` +- `inspect` Preferred helper flow: ```bash -pyro host connect codex -pyro host doctor +pyro host connect codex --mode repro-fix +pyro host connect codex --mode inspect +pyro host doctor --mode repro-fix ``` Package without install: ```bash -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix codex mcp list ``` @@ -22,7 +26,7 @@ from the current checkout automatically. Already installed: ```bash -codex mcp add pyro -- pyro mcp serve +codex mcp add pyro -- pyro mcp serve --mode repro-fix codex mcp list ``` @@ -30,14 +34,14 @@ If Codex launches the server from an unexpected cwd, pin the project explicitly: ```bash -pyro host connect codex --project-path /abs/path/to/repo -codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --project-path /abs/path/to/repo +pyro host connect codex --mode repro-fix --project-path /abs/path/to/repo +codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix --project-path /abs/path/to/repo ``` If the local config drifts later: ```bash -pyro host repair codex +pyro host repair codex --mode repro-fix ``` Move to `workspace-full` only when the chat truly needs shells, services, diff --git a/examples/mcp_client_config.md b/examples/mcp_client_config.md index d0b2046..f4de5f0 100644 --- a/examples/mcp_client_config.md +++ b/examples/mcp_client_config.md @@ -1,6 +1,11 @@ # MCP Client Config Example -Default for most chat hosts in `4.x`: `workspace-core`. +Recommended named modes for most chat hosts in `4.x`: + +- `repro-fix` +- `inspect` +- `cold-start` +- `review-eval` Use the host-specific examples first when they apply: @@ -10,14 +15,16 @@ Use the host-specific examples first when they apply: 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` +- `pyro host connect codex --mode repro-fix` +- `pyro host connect codex --mode inspect` +- `pyro host connect claude-code --mode cold-start` +- `pyro host connect claude-code --mode review-eval` +- `pyro host print-config opencode --mode repro-fix` +- `pyro host doctor --mode repro-fix` +- `pyro host repair opencode --mode repro-fix` Use this generic config only when the host expects a plain `mcpServers` JSON -shape. +shape or when the named modes are too narrow for the workflow. `pyro-mcp` is intended to be exposed to LLM clients through the public `pyro` CLI. @@ -28,7 +35,7 @@ Generic stdio MCP configuration using `uvx`: "mcpServers": { "pyro": { "command": "uvx", - "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve"] + "args": ["--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } @@ -41,7 +48,7 @@ If `pyro-mcp` is already installed locally, the same server can be configured wi "mcpServers": { "pyro": { "command": "pyro", - "args": ["mcp", "serve"] + "args": ["mcp", "serve", "--mode", "repro-fix"] } } } @@ -51,15 +58,18 @@ If the host does not preserve the server working directory and you want the first `workspace_create` to start from a specific checkout, add `"--project-path", "/abs/path/to/repo"` after `"serve"` in the same args list. -Profile progression: +Mode progression: -- `workspace-core`: the default and recommended first persistent chat profile -- `vm-run`: expose only `vm_run` +- `repro-fix`: patch, rerun, diff, export, reset +- `inspect`: narrow offline-by-default inspection +- `cold-start`: validation plus service readiness +- `review-eval`: shell and snapshot-driven review +- generic no-mode path: the fallback when the named mode is too narrow - `workspace-full`: explicit advanced opt-in for shells, services, snapshots, secrets, network policy, and disk tools -Primary profile for most agents: +Primary mode for most agents: -- `workspace-core` +- `repro-fix` Use lifecycle tools only when the agent needs persistent VM state across multiple tool calls. diff --git a/examples/opencode_mcp_config.json b/examples/opencode_mcp_config.json index c060946..518dc7d 100644 --- a/examples/opencode_mcp_config.json +++ b/examples/opencode_mcp_config.json @@ -3,7 +3,7 @@ "pyro": { "type": "local", "enabled": true, - "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"] + "command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve", "--mode", "repro-fix"] } } } diff --git a/pyproject.toml b/pyproject.toml index 963ecdd..796ba47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.3.0" +version = "4.4.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/src/pyro_mcp/api.py b/src/pyro_mcp/api.py index 935c619..967b05c 100644 --- a/src/pyro_mcp/api.py +++ b/src/pyro_mcp/api.py @@ -8,7 +8,12 @@ from typing import Any, Literal, cast from mcp.server.fastmcp import FastMCP from pyro_mcp.contract import ( + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_INSPECT_MODE_TOOLS, + PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, @@ -30,12 +35,77 @@ from pyro_mcp.vm_manager import ( ) McpToolProfile = Literal["vm-run", "workspace-core", "workspace-full"] +WorkspaceUseCaseMode = Literal["repro-fix", "inspect", "cold-start", "review-eval"] _PROFILE_TOOLS: dict[str, tuple[str, ...]] = { "vm-run": PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, "workspace-core": PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, "workspace-full": PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, } +_MODE_TOOLS: dict[str, tuple[str, ...]] = { + "repro-fix": PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + "inspect": PUBLIC_MCP_INSPECT_MODE_TOOLS, + "cold-start": PUBLIC_MCP_COLD_START_MODE_TOOLS, + "review-eval": PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, +} +_MODE_CREATE_INTENT: dict[str, str] = { + "repro-fix": "to reproduce a failure, patch files, rerun, diff, export, and reset", + "inspect": "to inspect suspicious or unfamiliar code with the smallest persistent surface", + "cold-start": ( + "to validate a fresh repo, keep one service alive, and export a " + "validation report" + ), + "review-eval": "to review interactively, checkpoint work, and export the final report", +} +_MODE_TOOL_DESCRIPTIONS: dict[str, dict[str, str]] = { + "repro-fix": { + "workspace_file_read": "Read one workspace file while investigating the broken state.", + "workspace_file_write": "Write one workspace file directly as part of the fix loop.", + "workspace_patch_apply": "Apply a structured text patch inside the workspace fix loop.", + "workspace_export": "Export the fixed result or patch-ready file back to the host.", + "workspace_summary": ( + "Summarize the current repro/fix session for review before export " + "or reset." + ), + }, + "inspect": { + "workspace_file_list": "List suspicious files under the current workspace path.", + "workspace_file_read": "Read one suspicious or unfamiliar workspace file.", + "workspace_export": ( + "Export only the inspection report or artifact you chose to " + "materialize." + ), + "workspace_summary": "Summarize the current inspection session and its exported results.", + }, + "cold-start": { + "workspace_create": "Create and start a persistent workspace for cold-start validation.", + "workspace_export": "Export the validation report or other final host-visible result.", + "workspace_summary": ( + "Summarize the current validation session, service state, and " + "exports." + ), + "service_start": "Start a named validation or app service and wait for typed readiness.", + "service_list": "List running and exited validation services in the current workspace.", + "service_status": "Inspect one validation service and its readiness outcome.", + "service_logs": "Read stdout and stderr from one validation service.", + "service_stop": "Stop one validation service in the workspace.", + }, + "review-eval": { + "workspace_create": ( + "Create and start a persistent workspace for interactive review " + "or evaluation." + ), + "workspace_summary": "Summarize the current review session before exporting or resetting.", + "shell_open": "Open an interactive review shell inside the workspace.", + "shell_read": "Read chat-friendly PTY output from the current review shell.", + "shell_write": "Send one line of input to the current review shell.", + "shell_signal": "Interrupt or terminate the current review shell process group.", + "shell_close": "Close the current review shell.", + "snapshot_create": "Create a checkpoint before the review branch diverges.", + "snapshot_list": "List the baseline plus named review checkpoints.", + "snapshot_delete": "Delete one named review checkpoint.", + }, +} def _validate_mcp_profile(profile: str) -> McpToolProfile: @@ -45,16 +115,42 @@ def _validate_mcp_profile(profile: str) -> McpToolProfile: return cast(McpToolProfile, profile) -def _workspace_create_description(startup_source: ProjectStartupSource | None) -> str: +def _validate_workspace_mode(mode: str) -> WorkspaceUseCaseMode: + if mode not in PUBLIC_MCP_MODES: + expected = ", ".join(PUBLIC_MCP_MODES) + raise ValueError(f"unknown workspace mode {mode!r}; expected one of: {expected}") + return cast(WorkspaceUseCaseMode, mode) + + +def _workspace_create_description( + startup_source: ProjectStartupSource | None, + *, + mode: WorkspaceUseCaseMode | None = None, +) -> str: + if mode is not None: + prefix = ( + "Create and start a persistent workspace " + f"{_MODE_CREATE_INTENT[mode]}." + ) + else: + prefix = "Create and start a persistent workspace." if startup_source is None: - return "Create and start a persistent workspace." + return prefix described_source = describe_project_startup_source(startup_source) if described_source is None: - return "Create and start a persistent workspace." - return ( - "Create and start a persistent workspace. If `seed_path` is omitted, " - f"the server seeds from {described_source}." - ) + return prefix + return f"{prefix} If `seed_path` is omitted, the server seeds from {described_source}." + + +def _tool_description( + tool_name: str, + *, + mode: WorkspaceUseCaseMode | None, + fallback: str, +) -> str: + if mode is None: + return fallback + return _MODE_TOOL_DESCRIPTIONS.get(mode, {}).get(tool_name, fallback) class Pyro: @@ -487,6 +583,7 @@ class Pyro: self, *, profile: McpToolProfile = "workspace-core", + mode: WorkspaceUseCaseMode | None = None, project_path: str | Path | None = None, repo_url: str | None = None, repo_ref: str | None = None, @@ -502,13 +599,20 @@ class Pyro: `repo_url`, and `no_project_source` override that behavior explicitly. """ normalized_profile = _validate_mcp_profile(profile) + normalized_mode = _validate_workspace_mode(mode) if mode is not None else None + if normalized_mode is not None and normalized_profile != "workspace-core": + raise ValueError("mode and profile are mutually exclusive") startup_source = resolve_project_startup_source( project_path=project_path, repo_url=repo_url, repo_ref=repo_ref, no_project_source=no_project_source, ) - enabled_tools = set(_PROFILE_TOOLS[normalized_profile]) + enabled_tools = set( + _MODE_TOOLS[normalized_mode] + if normalized_mode is not None + else _PROFILE_TOOLS[normalized_profile] + ) server = FastMCP(name="pyro_mcp") def _enabled(tool_name: str) -> bool: @@ -621,7 +725,10 @@ class Pyro: return self.reap_expired() if _enabled("workspace_create"): - workspace_create_description = _workspace_create_description(startup_source) + workspace_create_description = _workspace_create_description( + startup_source, + mode=normalized_mode, + ) def _create_workspace_from_server_defaults( *, @@ -668,7 +775,7 @@ class Pyro: _prepared_seed=prepared_seed, ) - if normalized_profile == "workspace-core": + if normalized_mode is not None or normalized_profile == "workspace-core": @server.tool(name="workspace_create", description=workspace_create_description) async def workspace_create_core( @@ -749,15 +856,21 @@ class Pyro: ) if _enabled("workspace_exec"): - if normalized_profile == "workspace-core": + if normalized_mode is not None or normalized_profile == "workspace-core": - @server.tool(name="workspace_exec") + @server.tool( + name="workspace_exec", + description=_tool_description( + "workspace_exec", + mode=normalized_mode, + fallback="Run one command inside an existing persistent workspace.", + ), + ) async def workspace_exec_core( workspace_id: str, command: str, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, ) -> dict[str, Any]: - """Run one command inside an existing persistent workspace.""" return self.exec_workspace( workspace_id, command=command, @@ -767,14 +880,20 @@ class Pyro: else: - @server.tool(name="workspace_exec") + @server.tool( + name="workspace_exec", + description=_tool_description( + "workspace_exec", + mode=normalized_mode, + fallback="Run one command inside an existing persistent workspace.", + ), + ) async def workspace_exec_full( workspace_id: str, command: str, timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, secret_env: dict[str, str] | None = None, ) -> dict[str, Any]: - """Run one command inside an existing persistent workspace.""" return self.exec_workspace( workspace_id, command=command, @@ -823,20 +942,30 @@ class Pyro: if _enabled("workspace_summary"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_summary", + mode=normalized_mode, + fallback="Summarize the current workspace session for human review.", + ) + ) async def workspace_summary(workspace_id: str) -> dict[str, Any]: - """Summarize the current workspace session for human review.""" return self.summarize_workspace(workspace_id) if _enabled("workspace_export"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_export", + mode=normalized_mode, + fallback="Export one file or directory from `/workspace` back to the host.", + ) + ) async def workspace_export( workspace_id: str, path: str, output_path: str, ) -> dict[str, Any]: - """Export one file or directory from `/workspace` back to the host.""" return self.export_workspace(workspace_id, path, output_path=output_path) if _enabled("workspace_diff"): @@ -848,13 +977,21 @@ class Pyro: if _enabled("workspace_file_list"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_file_list", + mode=normalized_mode, + fallback=( + "List metadata for files and directories under one " + "live workspace path." + ), + ) + ) async def workspace_file_list( workspace_id: str, path: str = "/workspace", recursive: bool = False, ) -> dict[str, Any]: - """List metadata for files and directories under one live workspace path.""" return self.list_workspace_files( workspace_id, path=path, @@ -863,13 +1000,18 @@ class Pyro: if _enabled("workspace_file_read"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_file_read", + mode=normalized_mode, + fallback="Read one regular text file from a live workspace path.", + ) + ) async def workspace_file_read( workspace_id: str, path: str, max_bytes: int = 65536, ) -> dict[str, Any]: - """Read one regular text file from a live workspace path.""" return self.read_workspace_file( workspace_id, path, @@ -878,13 +1020,18 @@ class Pyro: if _enabled("workspace_file_write"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_file_write", + mode=normalized_mode, + fallback="Create or replace one regular text file under `/workspace`.", + ) + ) async def workspace_file_write( workspace_id: str, path: str, text: str, ) -> dict[str, Any]: - """Create or replace one regular text file under `/workspace`.""" return self.write_workspace_file( workspace_id, path, @@ -893,12 +1040,17 @@ class Pyro: if _enabled("workspace_patch_apply"): - @server.tool() + @server.tool( + description=_tool_description( + "workspace_patch_apply", + mode=normalized_mode, + fallback="Apply a unified text patch inside one live workspace.", + ) + ) async def workspace_patch_apply( workspace_id: str, patch: str, ) -> dict[str, Any]: - """Apply a unified text patch inside one live workspace.""" return self.apply_workspace_patch( workspace_id, patch=patch, @@ -976,27 +1128,62 @@ class Pyro: return self.reset_workspace(workspace_id, snapshot=snapshot) if _enabled("shell_open"): + if normalized_mode == "review-eval": - @server.tool() - async def shell_open( - workspace_id: str, - cwd: str = "/workspace", - cols: int = 120, - rows: int = 30, - secret_env: dict[str, str] | None = None, - ) -> dict[str, Any]: - """Open a persistent interactive shell inside one workspace.""" - return self.open_shell( - workspace_id, - cwd=cwd, - cols=cols, - rows=rows, - secret_env=secret_env, + @server.tool( + description=_tool_description( + "shell_open", + mode=normalized_mode, + fallback="Open a persistent interactive shell inside one workspace.", + ) ) + async def shell_open( + workspace_id: str, + cwd: str = "/workspace", + cols: int = 120, + rows: int = 30, + ) -> dict[str, Any]: + return self.open_shell( + workspace_id, + cwd=cwd, + cols=cols, + rows=rows, + secret_env=None, + ) + + else: + + @server.tool( + description=_tool_description( + "shell_open", + mode=normalized_mode, + fallback="Open a persistent interactive shell inside one workspace.", + ) + ) + async def shell_open( + workspace_id: str, + cwd: str = "/workspace", + cols: int = 120, + rows: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, Any]: + return self.open_shell( + workspace_id, + cwd=cwd, + cols=cols, + rows=rows, + secret_env=secret_env, + ) if _enabled("shell_read"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_read", + mode=normalized_mode, + fallback="Read merged PTY output from a workspace shell.", + ) + ) async def shell_read( workspace_id: str, shell_id: str, @@ -1005,7 +1192,6 @@ class Pyro: plain: bool = False, wait_for_idle_ms: int | None = None, ) -> dict[str, Any]: - """Read merged PTY output from a workspace shell.""" return self.read_shell( workspace_id, shell_id, @@ -1017,14 +1203,19 @@ class Pyro: if _enabled("shell_write"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_write", + mode=normalized_mode, + fallback="Write text input to a persistent workspace shell.", + ) + ) async def shell_write( workspace_id: str, shell_id: str, input: str, append_newline: bool = True, ) -> dict[str, Any]: - """Write text input to a persistent workspace shell.""" return self.write_shell( workspace_id, shell_id, @@ -1034,13 +1225,18 @@ class Pyro: if _enabled("shell_signal"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_signal", + mode=normalized_mode, + fallback="Send a signal to the shell process group.", + ) + ) async def shell_signal( workspace_id: str, shell_id: str, signal_name: str = "INT", ) -> dict[str, Any]: - """Send a signal to the shell process group.""" return self.signal_shell( workspace_id, shell_id, @@ -1049,74 +1245,142 @@ class Pyro: if _enabled("shell_close"): - @server.tool() + @server.tool( + description=_tool_description( + "shell_close", + mode=normalized_mode, + fallback="Close a persistent workspace shell.", + ) + ) async def shell_close(workspace_id: str, shell_id: str) -> dict[str, Any]: - """Close a persistent workspace shell.""" return self.close_shell(workspace_id, shell_id) if _enabled("service_start"): + if normalized_mode == "cold-start": - @server.tool() - async def service_start( - workspace_id: str, - service_name: str, - command: str, - cwd: str = "/workspace", - ready_file: str | None = None, - ready_tcp: str | None = None, - ready_http: str | None = None, - ready_command: str | None = None, - ready_timeout_seconds: int = 30, - ready_interval_ms: int = 500, - secret_env: dict[str, str] | None = None, - published_ports: list[dict[str, int | None]] | None = None, - ) -> dict[str, Any]: - """Start a named long-running service inside a workspace.""" - readiness: dict[str, Any] | None = None - if ready_file is not None: - readiness = {"type": "file", "path": ready_file} - elif ready_tcp is not None: - readiness = {"type": "tcp", "address": ready_tcp} - elif ready_http is not None: - readiness = {"type": "http", "url": ready_http} - elif ready_command is not None: - readiness = {"type": "command", "command": ready_command} - return self.start_service( - workspace_id, - service_name, - command=command, - cwd=cwd, - readiness=readiness, - ready_timeout_seconds=ready_timeout_seconds, - ready_interval_ms=ready_interval_ms, - secret_env=secret_env, - published_ports=published_ports, + @server.tool( + description=_tool_description( + "service_start", + mode=normalized_mode, + fallback="Start a named long-running service inside a workspace.", + ) ) + async def service_start( + workspace_id: str, + service_name: str, + command: str, + cwd: str = "/workspace", + ready_file: str | None = None, + ready_tcp: str | None = None, + ready_http: str | None = None, + ready_command: str | None = None, + ready_timeout_seconds: int = 30, + ready_interval_ms: int = 500, + ) -> dict[str, Any]: + readiness: dict[str, Any] | None = None + if ready_file is not None: + readiness = {"type": "file", "path": ready_file} + elif ready_tcp is not None: + readiness = {"type": "tcp", "address": ready_tcp} + elif ready_http is not None: + readiness = {"type": "http", "url": ready_http} + elif ready_command is not None: + readiness = {"type": "command", "command": ready_command} + return self.start_service( + workspace_id, + service_name, + command=command, + cwd=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + secret_env=None, + published_ports=None, + ) + + else: + + @server.tool( + description=_tool_description( + "service_start", + mode=normalized_mode, + fallback="Start a named long-running service inside a workspace.", + ) + ) + async def service_start( + workspace_id: str, + service_name: str, + command: str, + cwd: str = "/workspace", + ready_file: str | None = None, + ready_tcp: str | None = None, + ready_http: str | None = None, + ready_command: str | None = None, + ready_timeout_seconds: int = 30, + ready_interval_ms: int = 500, + secret_env: dict[str, str] | None = None, + published_ports: list[dict[str, int | None]] | None = None, + ) -> dict[str, Any]: + readiness: dict[str, Any] | None = None + if ready_file is not None: + readiness = {"type": "file", "path": ready_file} + elif ready_tcp is not None: + readiness = {"type": "tcp", "address": ready_tcp} + elif ready_http is not None: + readiness = {"type": "http", "url": ready_http} + elif ready_command is not None: + readiness = {"type": "command", "command": ready_command} + return self.start_service( + workspace_id, + service_name, + command=command, + cwd=cwd, + readiness=readiness, + ready_timeout_seconds=ready_timeout_seconds, + ready_interval_ms=ready_interval_ms, + secret_env=secret_env, + published_ports=published_ports, + ) if _enabled("service_list"): - @server.tool() + @server.tool( + description=_tool_description( + "service_list", + mode=normalized_mode, + fallback="List named services in one workspace.", + ) + ) async def service_list(workspace_id: str) -> dict[str, Any]: - """List named services in one workspace.""" return self.list_services(workspace_id) if _enabled("service_status"): - @server.tool() + @server.tool( + description=_tool_description( + "service_status", + mode=normalized_mode, + fallback="Inspect one named workspace service.", + ) + ) async def service_status(workspace_id: str, service_name: str) -> dict[str, Any]: - """Inspect one named workspace service.""" return self.status_service(workspace_id, service_name) if _enabled("service_logs"): - @server.tool() + @server.tool( + description=_tool_description( + "service_logs", + mode=normalized_mode, + fallback="Read persisted stdout/stderr for one workspace service.", + ) + ) async def service_logs( workspace_id: str, service_name: str, tail_lines: int = 200, all: bool = False, ) -> dict[str, Any]: - """Read persisted stdout/stderr for one workspace service.""" return self.logs_service( workspace_id, service_name, @@ -1126,9 +1390,14 @@ class Pyro: if _enabled("service_stop"): - @server.tool() + @server.tool( + description=_tool_description( + "service_stop", + mode=normalized_mode, + fallback="Stop one running service in a workspace.", + ) + ) async def service_stop(workspace_id: str, service_name: str) -> dict[str, Any]: - """Stop one running service in a workspace.""" return self.stop_service(workspace_id, service_name) if _enabled("workspace_delete"): diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index cfb6c86..5b5293b 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -11,8 +11,8 @@ from textwrap import dedent from typing import Any, cast from pyro_mcp import __version__ -from pyro_mcp.api import McpToolProfile, Pyro -from pyro_mcp.contract import PUBLIC_MCP_PROFILES +from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode +from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES from pyro_mcp.demo import run_demo from pyro_mcp.host_helpers import ( HostDoctorEntry, @@ -181,6 +181,7 @@ 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"))), + mode=cast(WorkspaceUseCaseMode | None, getattr(args, "mode", None)), project_path=getattr(args, "project_path", None), repo_url=getattr(args, "repo_url", None), repo_ref=getattr(args, "repo_ref", None), @@ -856,11 +857,17 @@ def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None: action="store_true", help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.", ) - parser.add_argument( + profile_group = parser.add_mutually_exclusive_group() + profile_group.add_argument( "--profile", choices=PUBLIC_MCP_PROFILES, default="workspace-core", - help="Server profile to configure for the host helper flow.", + help="Explicit profile for the host helper flow when not using a named mode.", + ) + profile_group.add_argument( + "--mode", + choices=PUBLIC_MCP_MODES, + help="Opinionated use-case mode for the host helper flow.", ) source_group = parser.add_mutually_exclusive_group() source_group.add_argument( @@ -1017,6 +1024,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro host connect claude-code + pyro host connect claude-code --mode cold-start pyro host connect codex --project-path /abs/path/to/repo pyro host print-config opencode pyro host repair opencode @@ -1037,7 +1045,9 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro host connect claude-code + pyro host connect claude-code --mode cold-start pyro host connect codex --installed-package + pyro host connect codex --mode repro-fix pyro host connect codex --project-path /abs/path/to/repo """ ), @@ -1061,6 +1071,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro host print-config opencode + pyro host print-config opencode --mode repro-fix pyro host print-config opencode --output ./opencode.json pyro host print-config opencode --project-path /abs/path/to/repo """ @@ -1089,6 +1100,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro host doctor + pyro host doctor --mode inspect pyro host doctor --project-path /abs/path/to/repo pyro host doctor --installed-package """ @@ -1112,6 +1124,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro host repair claude-code + pyro host repair claude-code --mode review-eval pyro host repair codex --project-path /abs/path/to/repo pyro host repair opencode """ @@ -1141,6 +1154,8 @@ def _build_parser() -> argparse.ArgumentParser: """ Examples: pyro mcp serve + pyro mcp serve --mode repro-fix + pyro mcp serve --mode inspect pyro mcp serve --project-path . pyro mcp serve --repo-url https://github.com/example/project.git pyro mcp serve --profile vm-run @@ -1155,19 +1170,27 @@ def _build_parser() -> argparse.ArgumentParser: help="Run the MCP server over stdio.", description=( "Expose pyro tools over stdio for an MCP client. Bare `pyro mcp " - "serve` now starts `workspace-core`, the recommended first profile " - "for most chat hosts. When launched from inside a Git checkout, it " - "also seeds the first workspace from that repo by default." + "serve` starts the generic `workspace-core` path. Use `--mode` to " + "start from an opinionated use-case flow, or `--profile` to choose " + "a generic profile directly. When launched from inside a Git " + "checkout, it also seeds the first workspace from that repo by default." ), epilog=dedent( """ - Default and recommended first start: + Generic default path: pyro mcp serve pyro mcp serve --project-path . pyro mcp serve --repo-url https://github.com/example/project.git + Named modes: + repro-fix: structured edit / diff / export / reset loop + inspect: smallest persistent inspection surface + cold-start: validation plus service readiness + review-eval: shell plus snapshots for review workflows + Profiles: - workspace-core: default for normal persistent chat editing + workspace-core: default for normal persistent chat editing and the + recommended first profile for most chat hosts vm-run: smallest one-shot-only surface workspace-full: larger opt-in surface for shells, services, snapshots, secrets, network policy, and disk tools @@ -1178,22 +1201,28 @@ def _build_parser() -> argparse.ArgumentParser: - use --project-path when the host does not preserve cwd - use --repo-url for a clean-clone source outside a local checkout - Use --profile workspace-full only when the host truly needs those - extra workspace capabilities. + Use --mode when one named use case already matches the job. Fall + back to the generic no-mode path when the mode feels too narrow. """ ), formatter_class=_HelpFormatter, ) - mcp_serve_parser.add_argument( + mcp_profile_group = mcp_serve_parser.add_mutually_exclusive_group() + mcp_profile_group.add_argument( "--profile", choices=PUBLIC_MCP_PROFILES, default="workspace-core", help=( - "Expose only one model-facing tool profile. `workspace-core` is " - "the default and recommended first profile for most chat hosts; " - "`workspace-full` is the larger opt-in profile." + "Expose one generic model-facing tool profile instead of a named mode. " + "`workspace-core` is the generic default and `workspace-full` is the " + "larger opt-in profile." ), ) + mcp_profile_group.add_argument( + "--mode", + choices=PUBLIC_MCP_MODES, + help="Expose one opinionated use-case mode instead of the generic profile path.", + ) mcp_source_group = mcp_serve_parser.add_mutually_exclusive_group() mcp_source_group.add_argument( "--project-path", @@ -2794,6 +2823,7 @@ def main() -> None: if args.command == "mcp": pyro.create_server( profile=args.profile, + mode=getattr(args, "mode", None), project_path=args.project_path, repo_url=args.repo_url, repo_ref=args.repo_ref, diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index cddbbff..bebae39 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -8,6 +8,7 @@ PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") PUBLIC_CLI_HOST_COMMON_FLAGS = ( "--installed-package", + "--mode", "--profile", "--project-path", "--repo-url", @@ -20,6 +21,7 @@ PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output", PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",) PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",) PUBLIC_CLI_MCP_SERVE_FLAGS = ( + "--mode", "--profile", "--project-path", "--repo-url", @@ -140,6 +142,7 @@ PUBLIC_CLI_RUN_FLAGS = ( "--json", ) PUBLIC_MCP_PROFILES = ("vm-run", "workspace-core", "workspace-full") +PUBLIC_MCP_MODES = ("repro-fix", "inspect", "cold-start", "review-eval") PUBLIC_SDK_METHODS = ( "apply_workspace_patch", @@ -258,4 +261,77 @@ PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS = ( "workspace_sync_push", "workspace_update", ) +PUBLIC_MCP_REPRO_FIX_MODE_TOOLS = ( + "workspace_create", + "workspace_delete", + "workspace_diff", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_file_write", + "workspace_list", + "workspace_logs", + "workspace_patch_apply", + "workspace_reset", + "workspace_summary", + "workspace_status", + "workspace_sync_push", + "workspace_update", +) +PUBLIC_MCP_INSPECT_MODE_TOOLS = ( + "workspace_create", + "workspace_delete", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_list", + "workspace_logs", + "workspace_summary", + "workspace_status", + "workspace_update", +) +PUBLIC_MCP_COLD_START_MODE_TOOLS = ( + "service_list", + "service_logs", + "service_start", + "service_status", + "service_stop", + "workspace_create", + "workspace_delete", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_list", + "workspace_logs", + "workspace_reset", + "workspace_summary", + "workspace_status", + "workspace_update", +) +PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS = ( + "shell_close", + "shell_open", + "shell_read", + "shell_signal", + "shell_write", + "snapshot_create", + "snapshot_delete", + "snapshot_list", + "workspace_create", + "workspace_delete", + "workspace_diff", + "workspace_exec", + "workspace_export", + "workspace_file_list", + "workspace_file_read", + "workspace_list", + "workspace_logs", + "workspace_reset", + "workspace_summary", + "workspace_status", + "workspace_update", +) PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS = PUBLIC_MCP_TOOLS diff --git a/src/pyro_mcp/host_helpers.py b/src/pyro_mcp/host_helpers.py index 42420bb..dc06654 100644 --- a/src/pyro_mcp/host_helpers.py +++ b/src/pyro_mcp/host_helpers.py @@ -11,7 +11,7 @@ from datetime import UTC, datetime from pathlib import Path from typing import Literal -from pyro_mcp.api import McpToolProfile +from pyro_mcp.api import McpToolProfile, WorkspaceUseCaseMode SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex") SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode") @@ -26,6 +26,7 @@ HostStatus = Literal["drifted", "missing", "ok", "unavailable"] class HostServerConfig: installed_package: bool = False profile: McpToolProfile = "workspace-core" + mode: WorkspaceUseCaseMode | None = None project_path: str | None = None repo_url: str | None = None repo_ref: str | None = None @@ -60,6 +61,8 @@ def _host_binary(host: str) -> str: def _canonical_server_command(config: HostServerConfig) -> list[str]: + if config.mode is not None and config.profile != "workspace-core": + raise ValueError("--mode and --profile are mutually exclusive") 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 ( @@ -76,7 +79,9 @@ def _canonical_server_command(config: HostServerConfig) -> list[str]: command = ["pyro", "mcp", "serve"] if not config.installed_package: command = ["uvx", "--from", "pyro-mcp", *command] - if config.profile != "workspace-core": + if config.mode is not None: + command.extend(["--mode", config.mode]) + elif config.profile != "workspace-core": command.extend(["--profile", config.profile]) if config.project_path is not None: command.extend(["--project-path", config.project_path]) @@ -97,7 +102,9 @@ def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | command = ["pyro", "host", "repair", host] if config.installed_package: command.append("--installed-package") - if config.profile != "workspace-core": + if config.mode is not None: + command.extend(["--mode", config.mode]) + elif config.profile != "workspace-core": command.extend(["--profile", config.profile]) if config.project_path is not None: command.extend(["--project-path", config.project_path]) diff --git a/src/pyro_mcp/server.py b/src/pyro_mcp/server.py index 7206ba9..455e9d2 100644 --- a/src/pyro_mcp/server.py +++ b/src/pyro_mcp/server.py @@ -6,7 +6,7 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP -from pyro_mcp.api import McpToolProfile, Pyro +from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode from pyro_mcp.vm_manager import VmManager @@ -14,6 +14,7 @@ def create_server( manager: VmManager | None = None, *, profile: McpToolProfile = "workspace-core", + mode: WorkspaceUseCaseMode | None = None, project_path: str | Path | None = None, repo_url: str | None = None, repo_ref: str | None = None, @@ -21,7 +22,8 @@ def create_server( ) -> FastMCP: """Create and return a configured MCP server instance. - `workspace-core` is the default stable chat-host profile in 4.x. Use + Bare server creation uses the generic `workspace-core` path in 4.x. Use + `mode=...` for one of the named use-case surfaces, or `profile="workspace-full"` only when the host truly needs the full advanced workspace surface. By default, the server auto-detects the nearest Git worktree root from its current working directory for @@ -29,6 +31,7 @@ def create_server( """ return Pyro(manager=manager).create_server( profile=profile, + mode=mode, project_path=project_path, repo_url=repo_url, repo_ref=repo_ref, diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 82375e1..74b1fe6 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.3.0" +DEFAULT_CATALOG_VERSION = "4.4.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.3.0,<5.0.0" + compatibility: str = ">=4.4.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 26635d9..b69a90d 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -29,7 +29,7 @@ USE_CASE_CHOICES: Final[tuple[str, ...]] = USE_CASE_SCENARIOS + (USE_CASE_ALL_SC class WorkspaceUseCaseRecipe: scenario: str title: str - profile: Literal["workspace-core", "workspace-full"] + mode: Literal["repro-fix", "inspect", "cold-start", "review-eval"] smoke_target: str doc_path: str summary: str @@ -39,7 +39,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="cold-start-validation", title="Cold-Start Repo Validation", - profile="workspace-full", + mode="cold-start", smoke_target="smoke-cold-start-validation", doc_path="docs/use-cases/cold-start-repo-validation.md", summary=( @@ -50,7 +50,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="repro-fix-loop", title="Repro Plus Fix Loop", - profile="workspace-core", + mode="repro-fix", smoke_target="smoke-repro-fix-loop", doc_path="docs/use-cases/repro-fix-loop.md", summary=( @@ -61,7 +61,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="parallel-workspaces", title="Parallel Isolated Workspaces", - profile="workspace-core", + mode="repro-fix", smoke_target="smoke-parallel-workspaces", doc_path="docs/use-cases/parallel-workspaces.md", summary=( @@ -72,7 +72,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="untrusted-inspection", title="Unsafe Or Untrusted Code Inspection", - profile="workspace-core", + mode="inspect", smoke_target="smoke-untrusted-inspection", doc_path="docs/use-cases/untrusted-inspection.md", summary=( @@ -83,7 +83,7 @@ WORKSPACE_USE_CASE_RECIPES: Final[tuple[WorkspaceUseCaseRecipe, ...]] = ( WorkspaceUseCaseRecipe( scenario="review-eval", title="Review And Evaluation Workflows", - profile="workspace-full", + mode="review-eval", smoke_target="smoke-review-eval", doc_path="docs/use-cases/review-eval-workflows.md", summary=( @@ -141,11 +141,12 @@ def _create_project_aware_workspace( *, environment: str, project_path: Path, + mode: Literal["repro-fix", "cold-start"], name: str, labels: dict[str, str], ) -> dict[str, object]: async def _run() -> dict[str, object]: - server = pyro.create_server(profile="workspace-core", project_path=project_path) + server = pyro.create_server(mode=mode, project_path=project_path) return _extract_structured_tool_result( await server.call_tool( "workspace_create", @@ -194,14 +195,19 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str) ) workspace_id: str | None = None try: - workspace_id = _create_workspace( + created = _create_project_aware_workspace( pyro, environment=environment, - seed_path=seed_dir, + project_path=seed_dir, + mode="cold-start", name="cold-start-validation", labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "cold-start-validation"}, ) + workspace_id = str(created["workspace_id"]) _log(f"cold-start-validation workspace_id={workspace_id}") + workspace_seed = created["workspace_seed"] + assert isinstance(workspace_seed, dict), created + assert workspace_seed["origin_kind"] == "project_path", created validation = pyro.exec_workspace(workspace_id, command="sh validate.sh") assert int(validation["exit_code"]) == 0, validation assert str(validation["stdout"]) == "validated\n", validation @@ -259,6 +265,7 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non pyro, environment=environment, project_path=seed_dir, + mode="repro-fix", name="repro-fix-loop", labels={"suite": USE_CASE_SUITE_LABEL, "use_case": "repro-fix-loop"}, ) @@ -495,7 +502,7 @@ def run_workspace_use_case_scenario( scenario_names = USE_CASE_SCENARIOS if scenario == USE_CASE_ALL_SCENARIO else (scenario,) for scenario_name in scenario_names: recipe = _RECIPE_BY_SCENARIO[scenario_name] - _log(f"starting {recipe.scenario} ({recipe.title}) profile={recipe.profile}") + _log(f"starting {recipe.scenario} ({recipe.title}) mode={recipe.mode}") scenario_root = root / scenario_name scenario_root.mkdir(parents=True, exist_ok=True) runner = _SCENARIO_RUNNERS[scenario_name] diff --git a/tests/test_api.py b/tests/test_api.py index 2fb7d10..56b461f 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6,8 +6,14 @@ import time from pathlib import Path from typing import Any, cast +import pytest + from pyro_mcp.api import Pyro from pyro_mcp.contract import ( + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_INSPECT_MODE_TOOLS, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_FULL_PROFILE_TOOLS, @@ -157,6 +163,120 @@ def test_pyro_create_server_workspace_core_profile_registers_expected_tools_and_ assert "workspace_disk_export" not in tool_map +def test_pyro_create_server_repro_fix_mode_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(mode="repro-fix") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS)) + create_properties = tool_map["workspace_create"]["inputSchema"]["properties"] + assert "network_policy" not in create_properties + assert "secrets" not in create_properties + exec_properties = tool_map["workspace_exec"]["inputSchema"]["properties"] + assert "secret_env" not in exec_properties + assert "service_start" not in tool_map + assert "shell_open" not in tool_map + assert "snapshot_create" not in tool_map + assert "reproduce a failure" in str(tool_map["workspace_create"]["description"]) + + +def test_pyro_create_server_cold_start_mode_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(mode="cold-start") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS)) + assert "shell_open" not in tool_map + assert "snapshot_create" not in tool_map + service_start_properties = tool_map["service_start"]["inputSchema"]["properties"] + assert "secret_env" not in service_start_properties + assert "published_ports" not in service_start_properties + create_properties = tool_map["workspace_create"]["inputSchema"]["properties"] + assert "network_policy" not in create_properties + + +def test_pyro_create_server_review_eval_mode_registers_expected_tools_and_schemas( + tmp_path: Path, +) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> tuple[list[str], dict[str, dict[str, Any]]]: + server = pyro.create_server(mode="review-eval") + tools = await server.list_tools() + tool_map = {tool.name: tool.model_dump() for tool in tools} + return sorted(tool_map), tool_map + + tool_names, tool_map = asyncio.run(_run()) + assert tuple(tool_names) == tuple(sorted(PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS)) + assert "service_start" not in tool_map + assert "shell_open" in tool_map + assert "snapshot_create" in tool_map + shell_open_properties = tool_map["shell_open"]["inputSchema"]["properties"] + assert "secret_env" not in shell_open_properties + + +def test_pyro_create_server_inspect_mode_registers_expected_tools(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + async def _run() -> list[str]: + server = pyro.create_server(mode="inspect") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_INSPECT_MODE_TOOLS)) + + +def test_pyro_create_server_rejects_mode_and_non_default_profile(tmp_path: Path) -> None: + pyro = Pyro( + manager=VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + ) + + with pytest.raises(ValueError, match="mutually exclusive"): + pyro.create_server(profile="workspace-full", mode="repro-fix") + + def test_pyro_create_server_project_path_updates_workspace_create_description_and_default_seed( tmp_path: Path, ) -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 2d15c18..3b48e23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3101,7 +3101,7 @@ def test_cli_workspace_shell_open_prints_id_only( assert captured.err == "" -def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: +def test_chat_host_docs_and_examples_recommend_modes_first() -> None: readme = Path("README.md").read_text(encoding="utf-8") install = Path("docs/install.md").read_text(encoding="utf-8") first_run = Path("docs/first-run.md").read_text(encoding="utf-8") @@ -3110,19 +3110,24 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: claude_code = Path("examples/claude_code_mcp.md").read_text(encoding="utf-8") codex = Path("examples/codex_mcp.md").read_text(encoding="utf-8") opencode = json.loads(Path("examples/opencode_mcp_config.json").read_text(encoding="utf-8")) - claude_helper = "pyro host connect claude-code" - codex_helper = "pyro host connect codex" - opencode_helper = "pyro host print-config opencode" - claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" - codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve" + claude_helper = "pyro host connect claude-code --mode cold-start" + codex_helper = "pyro host connect codex --mode repro-fix" + inspect_helper = "pyro host connect codex --mode inspect" + review_helper = "pyro host connect claude-code --mode review-eval" + opencode_helper = "pyro host print-config opencode --mode repro-fix" + claude_cmd = "claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode cold-start" + codex_cmd = "codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve --mode repro-fix" assert "## Chat Host Quickstart" in readme assert claude_helper in readme assert codex_helper in readme + assert inspect_helper in readme + assert review_helper in readme assert opencode_helper in readme assert "examples/opencode_mcp_config.json" in readme assert "pyro host doctor" in readme - assert "bare `pyro mcp serve` starts `workspace-core`" in readme + assert "pyro mcp serve --mode repro-fix" in readme + assert "generic no-mode path" in readme assert "auto-detects the current Git checkout" in readme.replace("\n", " ") assert "--project-path /abs/path/to/repo" in readme assert "--repo-url https://github.com/example/project.git" in readme @@ -3130,44 +3135,54 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert "## 5. Connect a chat host" in install assert claude_helper in install assert codex_helper in install + assert inspect_helper in install + assert review_helper in install assert opencode_helper in install assert "workspace-full" in install assert "--project-path /abs/path/to/repo" in install + assert "pyro mcp serve --mode cold-start" in install assert claude_helper in first_run assert codex_helper in first_run + assert inspect_helper in first_run + assert review_helper in first_run assert opencode_helper in first_run assert "--project-path /abs/path/to/repo" in first_run + assert "pyro mcp serve --mode review-eval" in first_run assert claude_helper in integrations assert codex_helper in integrations + assert inspect_helper in integrations + assert review_helper in integrations assert opencode_helper in integrations - assert "Bare `pyro mcp serve` starts `workspace-core`." in integrations + assert "## Recommended Modes" in integrations + assert "pyro mcp serve --mode inspect" in integrations assert "auto-detects the current Git checkout" in integrations assert "examples/claude_code_mcp.md" in integrations assert "examples/codex_mcp.md" in integrations assert "examples/opencode_mcp_config.json" in integrations - assert "That is the product path." in integrations + assert "generic no-mode path" in integrations assert "--project-path /abs/path/to/repo" in integrations assert "--repo-url https://github.com/example/project.git" in integrations - assert "Default for most chat hosts in `4.x`: `workspace-core`." in mcp_config + assert "Recommended named modes for most chat hosts in `4.x`:" in mcp_config assert "Use the host-specific examples first when they apply:" in mcp_config assert "claude_code_mcp.md" in mcp_config assert "codex_mcp.md" in mcp_config assert "opencode_mcp_config.json" in mcp_config + assert '"serve", "--mode", "repro-fix"' in mcp_config assert claude_helper in claude_code assert claude_cmd in claude_code assert "claude mcp list" in claude_code - assert "pyro host repair claude-code" in claude_code + assert "pyro host repair claude-code --mode cold-start" in claude_code assert "workspace-full" in claude_code assert "--project-path /abs/path/to/repo" in claude_code assert codex_helper in codex assert codex_cmd in codex assert "codex mcp list" in codex - assert "pyro host repair codex" in codex + assert "pyro host repair codex --mode repro-fix" in codex assert "workspace-full" in codex assert "--project-path /abs/path/to/repo" in codex @@ -3183,6 +3198,8 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: "pyro", "mcp", "serve", + "--mode", + "repro-fix", ], } } @@ -4349,12 +4366,14 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: self, *, profile: str, + mode: str | None, project_path: str | None, repo_url: str | None, repo_ref: str | None, no_project_source: bool, ) -> Any: observed["profile"] = profile + observed["mode"] = mode observed["project_path"] = project_path observed["repo_url"] = repo_url observed["repo_ref"] = repo_ref @@ -4367,21 +4386,23 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="mcp", - mcp_command="serve", - profile="workspace-core", - project_path="/repo", - repo_url=None, - repo_ref=None, - no_project_source=False, - ) + return argparse.Namespace( + command="mcp", + mcp_command="serve", + profile="workspace-core", + mode=None, + project_path="/repo", + repo_url=None, + repo_ref=None, + no_project_source=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() assert observed == { "profile": "workspace-core", + "mode": None, "project_path": "/repo", "repo_url": None, "repo_ref": None, diff --git a/tests/test_host_helpers.py b/tests/test_host_helpers.py index 0f84ae1..2255208 100644 --- a/tests/test_host_helpers.py +++ b/tests/test_host_helpers.py @@ -131,6 +131,16 @@ def test_canonical_server_command_validates_and_renders_variants() -> None: "--repo-ref", "main", ] + assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [ + "uvx", + "--from", + "pyro-mcp", + "pyro", + "mcp", + "serve", + "--mode", + "repro-fix", + ] assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [ "uvx", "--from", @@ -149,6 +159,10 @@ def test_canonical_server_command_validates_and_renders_variants() -> None: _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")) + with pytest.raises(ValueError, match="mutually exclusive"): + _canonical_server_command( + HostServerConfig(profile="workspace-full", mode="repro-fix") + ) def test_repair_command_and_command_matches_cover_edge_cases() -> None: @@ -167,6 +181,9 @@ def test_repair_command_and_command_matches_cover_edge_cases() -> None: assert _repair_command("codex", HostServerConfig(no_project_source=True)) == ( "pyro host repair codex --no-project-source" ) + assert _repair_command("codex", HostServerConfig(mode="inspect")) == ( + "pyro host repair codex --mode inspect" + ) assert _command_matches( "pyro: uvx --from pyro-mcp pyro mcp serve", ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"], diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 527c68b..31fbdf1 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -62,7 +62,12 @@ from pyro_mcp.contract import ( PUBLIC_CLI_WORKSPACE_SYNC_PUSH_FLAGS, PUBLIC_CLI_WORKSPACE_SYNC_SUBCOMMANDS, PUBLIC_CLI_WORKSPACE_UPDATE_FLAGS, + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_INSPECT_MODE_TOOLS, + PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, + PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, PUBLIC_SDK_METHODS, ) @@ -139,6 +144,8 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: assert flag in mcp_serve_help_text for profile_name in PUBLIC_MCP_PROFILES: assert profile_name in mcp_serve_help_text + for mode_name in PUBLIC_MCP_MODES: + assert mode_name in mcp_serve_help_text workspace_help_text = _subparser_choice(parser, "workspace").format_help() for subcommand_name in PUBLIC_CLI_WORKSPACE_SUBCOMMANDS: @@ -372,6 +379,14 @@ def test_public_mcp_tools_match_contract(tmp_path: Path) -> None: assert tool_names == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) +def test_public_mcp_modes_are_declared_and_non_empty() -> None: + assert PUBLIC_MCP_MODES == ("repro-fix", "inspect", "cold-start", "review-eval") + assert PUBLIC_MCP_REPRO_FIX_MODE_TOOLS + assert PUBLIC_MCP_INSPECT_MODE_TOOLS + assert PUBLIC_MCP_COLD_START_MODE_TOOLS + assert PUBLIC_MCP_REVIEW_EVAL_MODE_TOOLS + + def test_pyproject_exposes_single_public_cli_script() -> None: pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) scripts = pyproject["project"]["scripts"] diff --git a/tests/test_server.py b/tests/test_server.py index 1017d77..1ad39b9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -9,6 +9,8 @@ import pytest import pyro_mcp.server as server_module from pyro_mcp.contract import ( + PUBLIC_MCP_COLD_START_MODE_TOOLS, + PUBLIC_MCP_REPRO_FIX_MODE_TOOLS, PUBLIC_MCP_VM_RUN_PROFILE_TOOLS, PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS, ) @@ -85,6 +87,36 @@ def test_create_server_workspace_core_profile_registers_expected_tools(tmp_path: assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_WORKSPACE_CORE_PROFILE_TOOLS)) +def test_create_server_repro_fix_mode_registers_expected_tools(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + async def _run() -> list[str]: + server = create_server(manager=manager, mode="repro-fix") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_REPRO_FIX_MODE_TOOLS)) + + +def test_create_server_cold_start_mode_registers_expected_tools(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "vms", + network_manager=TapNetworkManager(enabled=False), + ) + + async def _run() -> list[str]: + server = create_server(manager=manager, mode="cold-start") + tools = await server.list_tools() + return sorted(tool.name for tool in tools) + + assert tuple(asyncio.run(_run())) == tuple(sorted(PUBLIC_MCP_COLD_START_MODE_TOOLS)) + + def test_create_server_workspace_create_description_mentions_project_source(tmp_path: Path) -> None: manager = VmManager( backend_name="mock", diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index 8297c22..f369587 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -499,8 +499,15 @@ class _FakePyro: workspace.shells.pop(shell_id, None) return {"workspace_id": workspace_id, "shell_id": shell_id, "closed": True} - def create_server(self, *, profile: str, project_path: Path) -> Any: + def create_server( + self, + *, + profile: str = "workspace-core", + mode: str | None = None, + project_path: Path, + ) -> Any: assert profile == "workspace-core" + assert mode in {"repro-fix", "cold-start"} seed_path = Path(project_path) outer = self @@ -554,7 +561,7 @@ def test_use_case_docs_and_targets_stay_aligned() -> None: recipe_text = (repo_root / recipe.doc_path).read_text(encoding="utf-8") assert recipe.smoke_target in index_text assert recipe.doc_path.rsplit("/", 1)[-1] in index_text - assert recipe.profile in recipe_text + assert recipe.mode in recipe_text assert recipe.smoke_target in recipe_text assert f"{recipe.smoke_target}:" in makefile_text diff --git a/uv.lock b/uv.lock index 5944d05..b554f16 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.3.0" +version = "4.4.0" source = { editable = "." } dependencies = [ { name = "mcp" }, From 663241d5d2686e4745b3725172178ad3eaa43aae Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 21:17:59 -0300 Subject: [PATCH 7/8] Add daily-loop prepare and readiness checks Make the local chat-host loop explicit and cheap so users can warm the machine once instead of rediscovering environment and guest setup on every session. Add cache-backed daily-loop manifests plus the new `pyro prepare` flow, extend `pyro doctor --environment` with warm/cold/stale readiness reporting, and add `make smoke-daily-loop` to prove the warmed repro-fix reset path end to end. Also fix `python -m pyro_mcp.cli` to invoke `main()` so the new smoke and `dist-check` actually exercise the CLI module, and update the docs/roadmap to present `doctor -> prepare -> connect host -> reset` as the recommended daily path. Validation: `uv lock`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check`, `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check`, and `UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make smoke-daily-loop`. --- CHANGELOG.md | 11 + Makefile | 10 +- README.md | 33 +- docs/first-run.md | 21 +- docs/install.md | 47 ++- docs/integrations.md | 14 + docs/public-contract.md | 17 +- docs/roadmap/llm-chat-ergonomics.md | 11 +- .../4.5.0-faster-daily-loops.md | 20 +- pyproject.toml | 2 +- scripts/daily_loop_smoke.py | 8 + src/pyro_mcp/cli.py | 178 +++++++-- src/pyro_mcp/contract.py | 4 +- src/pyro_mcp/daily_loop.py | 152 ++++++++ src/pyro_mcp/daily_loop_smoke.py | 131 +++++++ src/pyro_mcp/doctor.py | 4 +- src/pyro_mcp/runtime.py | 43 ++- src/pyro_mcp/vm_environments.py | 4 +- src/pyro_mcp/vm_manager.py | 242 ++++++++---- tests/test_cli.py | 241 ++++++++++-- tests/test_daily_loop.py | 359 ++++++++++++++++++ tests/test_daily_loop_smoke.py | 138 +++++++ tests/test_doctor.py | 10 +- tests/test_public_contract.py | 8 + tests/test_runtime.py | 81 ++++ uv.lock | 2 +- 26 files changed, 1592 insertions(+), 199 deletions(-) create mode 100644 scripts/daily_loop_smoke.py create mode 100644 src/pyro_mcp/daily_loop.py create mode 100644 src/pyro_mcp/daily_loop_smoke.py create mode 100644 tests/test_daily_loop.py create mode 100644 tests/test_daily_loop_smoke.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 982d57d..7d099a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 4.5.0 + +- Added `pyro prepare` as the machine-level warmup path for the daily local + loop, with cached reuse when the runtime, catalog, and environment state are + already warm. +- Extended `pyro doctor` with daily-loop readiness output so users can see + whether the machine is cold, warm, or stale for `debian:12` before they + reconnect a chat host. +- Added `make smoke-daily-loop` to prove the warmed repro/fix/reset path end + to end on a real guest-backed machine. + ## 4.4.0 - Added explicit named MCP/server modes for the main workspace workflows: diff --git a/Makefile b/Makefile index c76fe4e..465a2ab 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,9 @@ TWINE_USERNAME ?= __token__ PYPI_REPOSITORY_URL ?= USE_CASE_ENVIRONMENT ?= debian:12 USE_CASE_SMOKE_FLAGS ?= +DAILY_LOOP_ENVIRONMENT ?= debian:12 -.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check +.PHONY: help setup lint format typecheck test check dist-check pypi-publish demo network-demo doctor ollama ollama-demo run-server install-hooks smoke-daily-loop smoke-use-cases smoke-cold-start-validation smoke-repro-fix-loop smoke-parallel-workspaces smoke-untrusted-inspection smoke-review-eval runtime-bundle runtime-binaries runtime-kernel runtime-rootfs runtime-agent runtime-validate runtime-manifest runtime-sync runtime-clean runtime-fetch-binaries runtime-build-kernel-real runtime-build-rootfs-real runtime-materialize runtime-export-environment-oci runtime-export-official-environments-oci runtime-publish-environment-oci runtime-publish-official-environments-oci runtime-boot-check runtime-network-check help: @printf '%s\n' \ @@ -36,6 +37,7 @@ help: ' demo Run the deterministic VM demo' \ ' network-demo Run the deterministic VM demo with guest networking enabled' \ ' doctor Show runtime and host diagnostics' \ + ' smoke-daily-loop Run the real guest-backed prepare plus reset daily-loop smoke' \ ' smoke-use-cases Run all real guest-backed workspace use-case smokes' \ ' smoke-cold-start-validation Run the cold-start repo validation smoke' \ ' smoke-repro-fix-loop Run the repro-plus-fix loop smoke' \ @@ -85,13 +87,14 @@ check: lint typecheck test dist-check: uv run python -m pyro_mcp.cli --version uv run python -m pyro_mcp.cli --help >/dev/null + uv run python -m pyro_mcp.cli prepare --help >/dev/null uv run python -m pyro_mcp.cli host --help >/dev/null uv run python -m pyro_mcp.cli host doctor >/dev/null uv run python -m pyro_mcp.cli mcp --help >/dev/null uv run python -m pyro_mcp.cli run --help >/dev/null uv run python -m pyro_mcp.cli env list >/dev/null uv run python -m pyro_mcp.cli env inspect debian:12 >/dev/null - uv run python -m pyro_mcp.cli doctor >/dev/null + uv run python -m pyro_mcp.cli doctor --environment debian:12 >/dev/null pypi-publish: @if [ -z "$$TWINE_PASSWORD" ]; then \ @@ -116,6 +119,9 @@ network-demo: doctor: uv run pyro doctor +smoke-daily-loop: + uv run python scripts/daily_loop_smoke.py --environment "$(DAILY_LOOP_ENVIRONMENT)" + smoke-use-cases: uv run python scripts/workspace_use_case_smoke.py --scenario all --environment "$(USE_CASE_ENVIRONMENT)" $(USE_CASE_SMOKE_FLAGS) diff --git a/README.md b/README.md index f433117..06956d1 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ SDK-first platform. - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) -- What's new in 4.4.0: [CHANGELOG.md#440](CHANGELOG.md#440) +- What's new in 4.5.0: [CHANGELOG.md#450](CHANGELOG.md#450) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) ## Who It's For @@ -54,8 +54,7 @@ Use either of these equivalent quickstart paths: # Package without install python -m pip install uv uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro env list -uvx --from pyro-mcp pyro env pull debian:12 +uvx --from pyro-mcp pyro prepare debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` @@ -64,8 +63,7 @@ uvx --from pyro-mcp pyro run debian:12 -- git --version ```bash # Already installed pyro doctor -pyro env list -pyro env pull debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version ``` @@ -91,12 +89,21 @@ git version ... The first pull downloads an OCI environment from public Docker Hub, requires outbound HTTPS access to `registry-1.docker.io`, and needs local cache space -for the guest image. +for the guest image. `pyro prepare debian:12` performs that install step +automatically, then proves create, exec, reset, and delete on one throwaway +workspace so the daily loop is warm before the chat host connects. ## Chat Host Quickstart -After the quickstart works, the intended next step is to connect a chat host in -one named mode. Use the helper flow first: +After the quickstart works, make the daily loop explicit before you connect the +chat host: + +```bash +uvx --from pyro-mcp pyro doctor --environment debian:12 +uvx --from pyro-mcp pyro prepare debian:12 +``` + +Then connect a chat host in one named mode. Use the helper flow first: ```bash uvx --from pyro-mcp pyro host connect codex --mode repro-fix @@ -198,13 +205,15 @@ snapshots, secrets, network policy, or disk tools. ## Zero To Hero 1. Validate the host with `pyro doctor`. -2. Pull `debian:12` and prove guest execution with `pyro run debian:12 -- git --version`. -3. Connect Claude Code, Codex, or OpenCode with one named mode such as +2. Warm the machine-level daily loop with `pyro prepare debian:12`. +3. Prove guest execution with `pyro run debian:12 -- git --version`. +4. Connect Claude Code, Codex, or OpenCode with one named mode such as `pyro host connect codex --mode repro-fix`, then fall back to raw `pyro mcp serve --mode ...` or the generic no-mode path when needed. -4. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). +5. Start with one recipe from [docs/use-cases/README.md](docs/use-cases/README.md). `repro-fix` is the shortest chat-first mode and story. -5. Use `make smoke-use-cases` as the trustworthy guest-backed verification path +6. Use `workspace reset` as the normal retry step inside that warmed loop. +7. Use `make smoke-use-cases` as the trustworthy guest-backed verification path for the advertised workflows. That is the intended user journey. The terminal commands exist to validate and diff --git a/docs/first-run.md b/docs/first-run.md index 894067c..04af501 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -14,13 +14,16 @@ path is still being shaped. ## 1. Verify the host ```bash -$ uvx --from pyro-mcp pyro doctor +$ uvx --from pyro-mcp pyro doctor --environment debian:12 Platform: linux-x86_64 Runtime: PASS KVM: exists=yes readable=yes writable=yes Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 Capabilities: vm_boot=yes guest_exec=yes guest_network=yes Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 ``` ## 2. Inspect the catalog @@ -71,6 +74,16 @@ streams, so they may appear in either order in terminals or capture tools. Use ## 5. Start the MCP server +Warm the daily loop first so the host is already ready for repeated create and +reset cycles: + +```bash +$ uvx --from pyro-mcp pyro prepare debian:12 +Prepare: debian:12 +Daily loop: WARM +Result: prepared network_prepared=no +``` + Use a named mode when one workflow already matches the job: ```bash @@ -191,6 +204,12 @@ That runner creates real guest-backed workspaces, exercises all five documented stories, exports concrete results where relevant, and cleans up on both success and failure. +For the machine-level warmup plus retry story specifically: + +```bash +$ make smoke-daily-loop +``` + ## 9. Optional one-shot demo ```bash diff --git a/docs/install.md b/docs/install.md index ed03f26..c809a35 100644 --- a/docs/install.md +++ b/docs/install.md @@ -46,29 +46,27 @@ Use either of these equivalent evaluator paths: ```bash # Package without install uvx --from pyro-mcp pyro doctor -uvx --from pyro-mcp pyro env list -uvx --from pyro-mcp pyro env pull debian:12 +uvx --from pyro-mcp pyro prepare debian:12 uvx --from pyro-mcp pyro run debian:12 -- git --version ``` ```bash # Already installed pyro doctor -pyro env list -pyro env pull debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version ``` If you are running from a repo checkout instead, replace `pyro` with `uv run pyro`. -After that one-shot proof works, the intended next step is a named chat mode -through `pyro host connect` or `pyro host print-config`. +After that one-shot proof works, the intended next step is a warmed daily loop +plus a named chat mode through `pyro host connect` or `pyro host print-config`. ## 1. Check the host ```bash -uvx --from pyro-mcp pyro doctor +uvx --from pyro-mcp pyro doctor --environment debian:12 ``` Expected success signals: @@ -78,8 +76,11 @@ Platform: linux-x86_64 Runtime: PASS KVM: exists=yes readable=yes writable=yes Environment cache: /home/you/.cache/pyro-mcp/environments +Catalog version: 4.5.0 Capabilities: vm_boot=yes guest_exec=yes guest_network=yes Networking: tun=yes ip_forward=yes +Daily loop: COLD (debian:12) + Run: pyro prepare debian:12 ``` If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md). @@ -139,7 +140,17 @@ The guest command output and the `[run] ...` summary are written to different streams, so they may appear in either order. Use `--json` if you need a deterministic structured result. -## 5. Connect a chat host +## 5. Warm the daily loop + +```bash +uvx --from pyro-mcp pyro prepare debian:12 +``` + +That one command ensures the environment is installed, proves one guest-backed +create/exec/reset/delete loop, and records a warm manifest so the next +`pyro prepare debian:12` call can reuse it instead of repeating the full cycle. + +## 6. Connect a chat host Use the helper flow first: @@ -221,23 +232,24 @@ Use the generic no-mode path when the named mode is too narrow. Move to `--profile workspace-full` only when the chat truly needs shells, services, snapshots, secrets, network policy, or disk tools. -## 6. Go from zero to hero +## 7. Go from zero to hero The intended user journey is: -1. validate the host with `pyro doctor` -2. pull `debian:12` +1. validate the host with `pyro doctor --environment debian:12` +2. warm the machine with `pyro prepare debian:12` 3. prove guest execution with `pyro run debian:12 -- git --version` 4. connect Claude Code, Codex, or OpenCode with one named mode such as `pyro host connect codex --mode repro-fix`, then use raw `pyro mcp serve --mode ...` or the generic no-mode path when needed -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` +5. use `workspace reset` as the normal retry step inside that warmed loop +6. start with one use-case recipe from [use-cases/README.md](use-cases/README.md) +7. trust but verify with `make smoke-use-cases` If you want the shortest chat-first story, start with [use-cases/repro-fix-loop.md](use-cases/repro-fix-loop.md). -## 7. Manual terminal workspace flow +## 8. Manual terminal workspace flow If you want to inspect the workspace model directly from the terminal, use the companion flow below. This is for understanding and debugging the chat-host @@ -269,7 +281,7 @@ When you need deeper debugging or richer recipes, add: private tokens - `pyro workspace stop` plus `workspace disk *` for offline inspection -## 8. Trustworthy verification path +## 9. Trustworthy verification path The five recipe docs in [use-cases/README.md](use-cases/README.md) are backed by a real Firecracker smoke pack: @@ -288,9 +300,8 @@ If you already installed the package, the same path works with plain `pyro ...`: ```bash uv tool install pyro-mcp pyro --version -pyro doctor -pyro env list -pyro env pull debian:12 +pyro doctor --environment debian:12 +pyro prepare debian:12 pyro run debian:12 -- git --version pyro mcp serve ``` diff --git a/docs/integrations.md b/docs/integrations.md index 1abf5a9..4974302 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -3,6 +3,7 @@ This page documents the intended product path for `pyro-mcp`: - validate the host with the CLI +- warm the daily loop with `pyro prepare debian:12` - run `pyro mcp serve` - connect a chat host - let the agent work inside disposable workspaces @@ -13,6 +14,13 @@ path is still being shaped. Use this page after you have already validated the host and guest execution through [install.md](install.md) or [first-run.md](first-run.md). +Recommended first commands before connecting a host: + +```bash +pyro doctor --environment debian:12 +pyro prepare debian:12 +``` + ## Recommended Modes Use a named mode when one workflow already matches the job: @@ -241,3 +249,9 @@ Validate the whole story with: ```bash make smoke-use-cases ``` + +For the machine-warmup plus reset/retry path specifically: + +```bash +make smoke-daily-loop +``` diff --git a/docs/public-contract.md b/docs/public-contract.md index 3d2265b..69412f0 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -30,11 +30,11 @@ documents the chat-host path the project is actively shaping. The intended user journey is: 1. `pyro doctor` -2. `pyro env list` -3. `pyro env pull debian:12` -4. `pyro run debian:12 -- git --version` -5. `pyro mcp serve` -6. connect Claude Code, Codex, or OpenCode +2. `pyro prepare debian:12` +3. `pyro run debian:12 -- git --version` +4. `pyro mcp serve` +5. connect Claude Code, Codex, or OpenCode +6. use `workspace reset` as the normal retry step 7. run one of the documented recipe-backed workflows 8. validate the whole story with `make smoke-use-cases` @@ -44,6 +44,7 @@ These terminal commands are the documented companion path for the chat-host product: - `pyro doctor` +- `pyro prepare` - `pyro env list` - `pyro env pull` - `pyro run` @@ -55,10 +56,12 @@ What to expect from that path: - `pyro run` fails if guest boot or guest exec is unavailable unless `--allow-host-compat` is set - `pyro run`, `pyro env list`, `pyro env pull`, `pyro env inspect`, - `pyro env prune`, and `pyro doctor` are human-readable by default and return - structured JSON with `--json` + `pyro env prune`, `pyro doctor`, and `pyro prepare` are human-readable by + default and return structured JSON with `--json` - the first official environment pull downloads from public Docker Hub into the local environment cache +- `pyro prepare debian:12` proves the warmed daily loop with one throwaway + workspace create, exec, reset, and delete cycle - `pyro demo` proves the one-shot create/start/exec/delete VM lifecycle end to end diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index be346fa..229d3da 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `4.4.0`: +Current baseline is `4.5.0`: - `pyro mcp serve` is now the default product entrypoint - `workspace-core` is now the default MCP profile @@ -83,7 +83,7 @@ capability gaps: 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) - Done 15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done -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) - Done Completed so far: @@ -126,10 +126,9 @@ Completed so far: - `4.4.0` adds named use-case modes so chat hosts can start from `repro-fix`, `inspect`, `cold-start`, or `review-eval` instead of choosing from the full generic workspace surface first. - -Planned next: - -- [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) +- `4.5.0` adds `pyro prepare`, daily-loop readiness in `pyro doctor`, and a + real `make smoke-daily-loop` verification path so the local machine warmup + story is explicit before the chat host connects. ## Expected Outcome diff --git a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md index 01c765c..ecbd8b8 100644 --- a/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md +++ b/docs/roadmap/llm-chat-ergonomics/4.5.0-faster-daily-loops.md @@ -1,6 +1,6 @@ # `4.5.0` Faster Daily Loops -Status: Planned +Status: Done ## Goal @@ -9,11 +9,11 @@ for normal work, not only for special high-isolation tasks. ## Public API Changes -The product should add an explicit fast-path for repeated local use, such as: +The product now adds an explicit fast-path for repeated local use: -- a prewarm or prepare path for the recommended environment and runtime -- a clearer fast reset or retry path for repeated repro-fix loops -- visible diagnostics for cache, prewarm, or ready-state health +- `pyro prepare [ENVIRONMENT] [--network] [--force] [--json]` +- `pyro doctor --environment ENVIRONMENT` daily-loop readiness output +- `make smoke-daily-loop` to prove the warmed machine plus reset/retry story The exact command names can still move, but the user-visible story needs to be: @@ -50,8 +50,8 @@ The exact command names can still move, but the user-visible story needs to be: ## Required Repo Updates -- docs updated to show the recommended daily-use fast path -- diagnostics and help text updated so the user can tell whether the machine is - already warm and ready -- at least one repeat-loop smoke or benchmark-style verification scenario - added to prevent regressions in the daily workflow +- docs now show the recommended daily-use fast path +- diagnostics and help text now show whether the machine is already warm and + ready +- the repo now includes `make smoke-daily-loop` as a repeat-loop verification + scenario for the daily workflow diff --git a/pyproject.toml b/pyproject.toml index 796ba47..5e9b649 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "4.4.0" +version = "4.5.0" description = "Disposable MCP workspaces for chat-based coding agents on Linux KVM." readme = "README.md" license = { file = "LICENSE" } diff --git a/scripts/daily_loop_smoke.py b/scripts/daily_loop_smoke.py new file mode 100644 index 0000000..dc40980 --- /dev/null +++ b/scripts/daily_loop_smoke.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +"""Run the real guest-backed daily-loop smoke.""" + +from pyro_mcp.daily_loop_smoke import main + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 5b5293b..79eb3a9 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -13,6 +13,7 @@ from typing import Any, cast from pyro_mcp import __version__ from pyro_mcp.api import McpToolProfile, Pyro, WorkspaceUseCaseMode from pyro_mcp.contract import PUBLIC_MCP_MODES, PUBLIC_MCP_PROFILES +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.demo import run_demo from pyro_mcp.host_helpers import ( HostDoctorEntry, @@ -154,6 +155,7 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: ) runtime = payload.get("runtime") if isinstance(runtime, dict): + print(f"Catalog version: {str(runtime.get('catalog_version', 'unknown'))}") print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}") capabilities = runtime.get("capabilities") if isinstance(capabilities, dict): @@ -171,12 +173,51 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} " f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}" ) + daily_loop = payload.get("daily_loop") + if isinstance(daily_loop, dict): + status = str(daily_loop.get("status", "cold")).upper() + environment = str(daily_loop.get("environment", DEFAULT_PREPARE_ENVIRONMENT)) + print(f"Daily loop: {status} ({environment})") + print( + " " + f"installed={'yes' if bool(daily_loop.get('installed')) else 'no'} " + f"network_prepared={'yes' if bool(daily_loop.get('network_prepared')) else 'no'}" + ) + prepared_at = daily_loop.get("prepared_at") + if prepared_at is not None: + print(f" prepared_at={prepared_at}") + reason = daily_loop.get("reason") + if isinstance(reason, str) and reason != "": + print(f" reason={reason}") + if str(daily_loop.get("status", "cold")) != "warm": + print(f" Run: pyro prepare {environment}") if isinstance(issues, list) and issues: print("Issues:") for issue in issues: print(f"- {issue}") +def _print_prepare_human(payload: dict[str, Any]) -> None: + environment = str(payload.get("environment", DEFAULT_PREPARE_ENVIRONMENT)) + status = str(payload.get("status", "cold")).upper() + print(f"Prepare: {environment}") + print(f"Daily loop: {status}") + print( + "Result: " + f"{'reused' if bool(payload.get('reused')) else 'prepared'} " + f"network_prepared={'yes' if bool(payload.get('network_prepared')) else 'no'}" + ) + print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}") + print(f"Manifest: {str(payload.get('manifest_path', 'unknown'))}") + prepared_at = payload.get("prepared_at") + if prepared_at is not None: + print(f"Prepared at: {prepared_at}") + print(f"Duration: {int(payload.get('last_prepare_duration_ms', 0))} ms") + reason = payload.get("reason") + if isinstance(reason, str) and reason != "": + print(f"Reason: {reason}") + + def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig: return HostServerConfig( installed_package=bool(getattr(args, "installed_package", False)), @@ -899,8 +940,7 @@ def _build_parser() -> argparse.ArgumentParser: """ Suggested zero-to-hero path: pyro doctor - pyro env list - pyro env pull debian:12 + pyro prepare debian:12 pyro run debian:12 -- git --version pyro host connect claude-code @@ -909,6 +949,11 @@ def _build_parser() -> argparse.ArgumentParser: pyro host connect codex pyro host print-config opencode + Daily local loop after the first warmup: + pyro doctor --environment debian:12 + pyro prepare debian:12 + pyro workspace reset WORKSPACE_ID + If you want terminal-level visibility into the workspace model: pyro workspace create debian:12 --seed-path ./repo --id-only pyro workspace sync push WORKSPACE_ID ./changes @@ -928,6 +973,51 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") + prepare_parser = subparsers.add_parser( + "prepare", + help="Warm the local machine for the daily workspace loop.", + description=( + "Warm the recommended guest-backed daily loop by ensuring the " + "environment is installed and proving create, exec, reset, and " + "delete on one throwaway workspace." + ), + epilog=dedent( + f""" + Examples: + pyro prepare + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --network + pyro prepare {DEFAULT_PREPARE_ENVIRONMENT} --force + """ + ), + formatter_class=_HelpFormatter, + ) + prepare_parser.add_argument( + "environment", + nargs="?", + default=DEFAULT_PREPARE_ENVIRONMENT, + metavar="ENVIRONMENT", + help=( + "Curated environment to warm for the daily loop. Defaults to " + f"`{DEFAULT_PREPARE_ENVIRONMENT}`." + ), + ) + prepare_parser.add_argument( + "--network", + action="store_true", + help="Also warm guest networking by proving one egress-enabled workspace cycle.", + ) + prepare_parser.add_argument( + "--force", + action="store_true", + help="Rerun warmup even when a compatible warm manifest already exists.", + ) + prepare_parser.add_argument( + "--json", + action="store_true", + help="Print structured JSON instead of human-readable output.", + ) + env_parser = subparsers.add_parser( "env", help="Inspect and manage curated environments.", @@ -1245,10 +1335,7 @@ def _build_parser() -> argparse.ArgumentParser: mcp_serve_parser.add_argument( "--no-project-source", action="store_true", - help=( - "Disable automatic Git checkout detection from the current working " - "directory." - ), + help=("Disable automatic Git checkout detection from the current working directory."), ) run_parser = subparsers.add_parser( @@ -1306,8 +1393,7 @@ def _build_parser() -> argparse.ArgumentParser: "--allow-host-compat", action="store_true", help=( - "Opt into host-side compatibility execution if guest boot or guest exec " - "is unavailable." + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." ), ) run_parser.add_argument( @@ -1428,8 +1514,7 @@ def _build_parser() -> argparse.ArgumentParser: "--allow-host-compat", action="store_true", help=( - "Opt into host-side compatibility execution if guest boot or guest exec " - "is unavailable." + "Opt into host-side compatibility execution if guest boot or guest exec is unavailable." ), ) workspace_create_parser.add_argument( @@ -1479,8 +1564,7 @@ def _build_parser() -> argparse.ArgumentParser: "exec", help="Run one command inside an existing workspace.", description=( - "Run one non-interactive command in the persistent `/workspace` " - "for a workspace." + "Run one non-interactive command in the persistent `/workspace` for a workspace." ), epilog=dedent( """ @@ -1716,8 +1800,7 @@ def _build_parser() -> argparse.ArgumentParser: "created automatically." ), epilog=( - "Example:\n" - " pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" + "Example:\n pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py" ), formatter_class=_HelpFormatter, ) @@ -1909,8 +1992,7 @@ def _build_parser() -> argparse.ArgumentParser: "start", help="Start one stopped workspace without resetting it.", description=( - "Start a previously stopped workspace from its preserved rootfs and " - "workspace state." + "Start a previously stopped workspace from its preserved rootfs and workspace state." ), epilog="Example:\n pyro workspace start WORKSPACE_ID", formatter_class=_HelpFormatter, @@ -2036,8 +2118,7 @@ def _build_parser() -> argparse.ArgumentParser: "shell", help="Open and manage persistent interactive shells.", description=( - "Open one or more persistent interactive PTY shell sessions inside a started " - "workspace." + "Open one or more persistent interactive PTY shell sessions inside a started workspace." ), epilog=dedent( """ @@ -2520,8 +2601,7 @@ while true; do sleep 60; done' "logs", help="Show command history for one workspace.", description=( - "Show persisted command history, including stdout and stderr, " - "for one workspace." + "Show persisted command history, including stdout and stderr, for one workspace." ), epilog="Example:\n pyro workspace logs WORKSPACE_ID", formatter_class=_HelpFormatter, @@ -2557,11 +2637,16 @@ while true; do sleep 60; done' doctor_parser = subparsers.add_parser( "doctor", help="Inspect runtime and host diagnostics.", - description="Check host prerequisites and embedded runtime health before your first run.", + description=( + "Check host prerequisites and embedded runtime health, plus " + "daily-loop warmth before your first run or before reconnecting a " + "chat host." + ), epilog=dedent( """ Examples: pyro doctor + pyro doctor --environment debian:12 pyro doctor --json """ ), @@ -2572,6 +2657,14 @@ while true; do sleep 60; done' default=DEFAULT_PLATFORM, help="Runtime platform to inspect.", ) + doctor_parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=( + "Environment to inspect for the daily-loop warm manifest. " + f"Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`." + ), + ) doctor_parser.add_argument( "--json", action="store_true", @@ -2734,6 +2827,24 @@ def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | def main() -> None: args = _build_parser().parse_args() pyro = Pyro() + if args.command == "prepare": + try: + payload = pyro.manager.prepare_daily_loop( + args.environment, + network=bool(args.network), + force=bool(args.force), + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc + if bool(args.json): + _print_json(payload) + else: + _print_prepare_human(payload) + return if args.command == "env": if args.env_command == "list": list_payload: dict[str, Any] = { @@ -2881,10 +2992,7 @@ def main() -> None: if args.command == "workspace": if args.workspace_command == "create": secrets = [ - *( - _parse_workspace_secret_option(value) - for value in getattr(args, "secret", []) - ), + *(_parse_workspace_secret_option(value) for value in getattr(args, "secret", [])), *( _parse_workspace_secret_file_option(value) for value in getattr(args, "secret_file", []) @@ -2919,9 +3027,7 @@ def main() -> None: return if args.workspace_command == "update": labels = _parse_workspace_label_options(getattr(args, "label", [])) - clear_labels = _parse_workspace_clear_label_options( - getattr(args, "clear_label", []) - ) + clear_labels = _parse_workspace_clear_label_options(getattr(args, "clear_label", [])) try: payload = pyro.update_workspace( args.workspace_id, @@ -3527,7 +3633,17 @@ def main() -> None: print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}") return if args.command == "doctor": - payload = doctor_report(platform=args.platform) + try: + payload = doctor_report( + platform=args.platform, + environment=args.environment, + ) + except Exception as exc: # noqa: BLE001 + if bool(args.json): + _print_json({"ok": False, "error": str(exc)}) + else: + print(f"[error] {exc}", file=sys.stderr, flush=True) + raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: @@ -3558,3 +3674,7 @@ def main() -> None: return result = run_demo(network=bool(args.network)) _print_json(result) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index bebae39..a7b2ba1 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -2,9 +2,10 @@ from __future__ import annotations -PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace") +PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "prepare", "run", "workspace") PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",) PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune") +PUBLIC_CLI_DOCTOR_FLAGS = ("--platform", "--environment", "--json") PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair") PUBLIC_CLI_HOST_COMMON_FLAGS = ( "--installed-package", @@ -28,6 +29,7 @@ PUBLIC_CLI_MCP_SERVE_FLAGS = ( "--repo-ref", "--no-project-source", ) +PUBLIC_CLI_PREPARE_FLAGS = ("--network", "--force", "--json") PUBLIC_CLI_WORKSPACE_SUBCOMMANDS = ( "create", "delete", diff --git a/src/pyro_mcp/daily_loop.py b/src/pyro_mcp/daily_loop.py new file mode 100644 index 0000000..164e1ba --- /dev/null +++ b/src/pyro_mcp/daily_loop.py @@ -0,0 +1,152 @@ +"""Machine-level daily-loop warmup state for the CLI prepare flow.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Literal + +DEFAULT_PREPARE_ENVIRONMENT = "debian:12" +PREPARE_MANIFEST_LAYOUT_VERSION = 1 +DailyLoopStatus = Literal["cold", "warm", "stale"] + + +def _environment_key(environment: str) -> str: + return environment.replace("/", "_").replace(":", "_") + + +@dataclass(frozen=True) +class DailyLoopManifest: + """Persisted machine-readiness proof for one environment on one platform.""" + + environment: str + environment_version: str + platform: str + catalog_version: str + bundle_version: str | None + prepared_at: float + network_prepared: bool + last_prepare_duration_ms: int + + def to_payload(self) -> dict[str, Any]: + return { + "layout_version": PREPARE_MANIFEST_LAYOUT_VERSION, + "environment": self.environment, + "environment_version": self.environment_version, + "platform": self.platform, + "catalog_version": self.catalog_version, + "bundle_version": self.bundle_version, + "prepared_at": self.prepared_at, + "network_prepared": self.network_prepared, + "last_prepare_duration_ms": self.last_prepare_duration_ms, + } + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> "DailyLoopManifest": + return cls( + environment=str(payload["environment"]), + environment_version=str(payload["environment_version"]), + platform=str(payload["platform"]), + catalog_version=str(payload["catalog_version"]), + bundle_version=( + None if payload.get("bundle_version") is None else str(payload["bundle_version"]) + ), + prepared_at=float(payload["prepared_at"]), + network_prepared=bool(payload.get("network_prepared", False)), + last_prepare_duration_ms=int(payload.get("last_prepare_duration_ms", 0)), + ) + + +def prepare_manifest_path(cache_dir: Path, *, platform: str, environment: str) -> Path: + return cache_dir / ".prepare" / platform / f"{_environment_key(environment)}.json" + + +def load_prepare_manifest(path: Path) -> tuple[DailyLoopManifest | None, str | None]: + if not path.exists(): + return None, None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return None, f"prepare manifest is unreadable: {exc}" + if not isinstance(payload, dict): + return None, "prepare manifest is not a JSON object" + try: + manifest = DailyLoopManifest.from_payload(payload) + except (KeyError, TypeError, ValueError) as exc: + return None, f"prepare manifest is invalid: {exc}" + return manifest, None + + +def write_prepare_manifest(path: Path, manifest: DailyLoopManifest) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(manifest.to_payload(), indent=2, sort_keys=True), + encoding="utf-8", + ) + + +def evaluate_daily_loop_status( + *, + environment: str, + environment_version: str, + platform: str, + catalog_version: str, + bundle_version: str | None, + installed: bool, + manifest: DailyLoopManifest | None, + manifest_error: str | None = None, +) -> tuple[DailyLoopStatus, str | None]: + if manifest_error is not None: + return "stale", manifest_error + if manifest is None: + if not installed: + return "cold", "environment is not installed" + return "cold", "daily loop has not been prepared yet" + if not installed: + return "stale", "environment install is missing" + if manifest.environment != environment: + return "stale", "prepare manifest environment does not match the selected environment" + if manifest.environment_version != environment_version: + return "stale", "environment version changed since the last prepare run" + if manifest.platform != platform: + return "stale", "platform changed since the last prepare run" + if manifest.catalog_version != catalog_version: + return "stale", "catalog version changed since the last prepare run" + if manifest.bundle_version != bundle_version: + return "stale", "runtime bundle version changed since the last prepare run" + return "warm", None + + +def prepare_request_is_satisfied( + manifest: DailyLoopManifest | None, + *, + require_network: bool, +) -> bool: + if manifest is None: + return False + if require_network and not manifest.network_prepared: + return False + return True + + +def serialize_daily_loop_report( + *, + environment: str, + status: DailyLoopStatus, + installed: bool, + cache_dir: Path, + manifest_path: Path, + reason: str | None, + manifest: DailyLoopManifest | None, +) -> dict[str, Any]: + return { + "environment": environment, + "status": status, + "installed": installed, + "network_prepared": bool(manifest.network_prepared) if manifest is not None else False, + "prepared_at": None if manifest is None else manifest.prepared_at, + "manifest_path": str(manifest_path), + "reason": reason, + "cache_dir": str(cache_dir), + } diff --git a/src/pyro_mcp/daily_loop_smoke.py b/src/pyro_mcp/daily_loop_smoke.py new file mode 100644 index 0000000..4cd82c7 --- /dev/null +++ b/src/pyro_mcp/daily_loop_smoke.py @@ -0,0 +1,131 @@ +"""Real guest-backed smoke for the daily local prepare and reset loop.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +from pyro_mcp.api import Pyro +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT + + +def _log(message: str) -> None: + print(f"[daily-loop] {message}", flush=True) + + +def _write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _run_prepare(environment: str) -> dict[str, object]: + proc = subprocess.run( # noqa: S603 + [sys.executable, "-m", "pyro_mcp.cli", "prepare", environment, "--json"], + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pyro prepare failed") + payload = json.loads(proc.stdout) + if not isinstance(payload, dict): + raise RuntimeError("pyro prepare did not return a JSON object") + return payload + + +def run_daily_loop_smoke(*, environment: str = DEFAULT_PREPARE_ENVIRONMENT) -> None: + _log(f"prepare environment={environment}") + first_prepare = _run_prepare(environment) + assert bool(first_prepare["prepared"]) is True, first_prepare + second_prepare = _run_prepare(environment) + assert bool(second_prepare["reused"]) is True, second_prepare + + pyro = Pyro() + with tempfile.TemporaryDirectory(prefix="pyro-daily-loop-") as temp_dir: + root = Path(temp_dir) + seed_dir = root / "seed" + export_dir = root / "export" + _write_text(seed_dir / "message.txt", "broken\n") + _write_text( + seed_dir / "check.sh", + "#!/bin/sh\n" + "set -eu\n" + "value=$(cat message.txt)\n" + '[ "$value" = "fixed" ] || {\n' + " printf 'expected fixed got %s\\n' \"$value\" >&2\n" + " exit 1\n" + "}\n" + "printf '%s\\n' \"$value\"\n", + ) + + workspace_id: str | None = None + try: + created = pyro.create_workspace( + environment=environment, + seed_path=seed_dir, + name="daily-loop", + labels={"suite": "daily-loop-smoke"}, + ) + workspace_id = str(created["workspace_id"]) + _log(f"workspace_id={workspace_id}") + + failing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(failing["exit_code"]) != 0, failing + + patched = pyro.apply_workspace_patch( + workspace_id, + patch=("--- a/message.txt\n+++ b/message.txt\n@@ -1 +1 @@\n-broken\n+fixed\n"), + ) + assert bool(patched["changed"]) is True, patched + + passing = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(passing["exit_code"]) == 0, passing + assert str(passing["stdout"]) == "fixed\n", passing + + export_path = export_dir / "message.txt" + exported = pyro.export_workspace( + workspace_id, + "message.txt", + output_path=export_path, + ) + assert export_path.read_text(encoding="utf-8") == "fixed\n" + assert str(exported["artifact_type"]) == "file", exported + + reset = pyro.reset_workspace(workspace_id) + assert int(reset["reset_count"]) == 1, reset + + rerun = pyro.exec_workspace(workspace_id, command="sh check.sh") + assert int(rerun["exit_code"]) != 0, rerun + reset_read = pyro.read_workspace_file(workspace_id, "message.txt") + assert str(reset_read["content"]) == "broken\n", reset_read + finally: + if workspace_id is not None: + try: + pyro.delete_workspace(workspace_id) + except Exception: + pass + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Run the real guest-backed daily-loop prepare and reset smoke.", + ) + parser.add_argument( + "--environment", + default=DEFAULT_PREPARE_ENVIRONMENT, + help=f"Environment to warm and test. Defaults to `{DEFAULT_PREPARE_ENVIRONMENT}`.", + ) + return parser + + +def main() -> None: + args = build_arg_parser().parse_args() + run_daily_loop_smoke(environment=args.environment) + + +if __name__ == "__main__": + main() diff --git a/src/pyro_mcp/doctor.py b/src/pyro_mcp/doctor.py index 296fedb..1de3ba3 100644 --- a/src/pyro_mcp/doctor.py +++ b/src/pyro_mcp/doctor.py @@ -5,16 +5,18 @@ from __future__ import annotations import argparse import json +from pyro_mcp.daily_loop import DEFAULT_PREPARE_ENVIRONMENT from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Inspect bundled runtime health for pyro-mcp.") parser.add_argument("--platform", default=DEFAULT_PLATFORM) + parser.add_argument("--environment", default=DEFAULT_PREPARE_ENVIRONMENT) return parser def main() -> None: args = _build_parser().parse_args() - report = doctor_report(platform=args.platform) + report = doctor_report(platform=args.platform, environment=args.environment) print(json.dumps(report, indent=2, sort_keys=True)) diff --git a/src/pyro_mcp/runtime.py b/src/pyro_mcp/runtime.py index 832e666..24779a3 100644 --- a/src/pyro_mcp/runtime.py +++ b/src/pyro_mcp/runtime.py @@ -11,6 +11,13 @@ from dataclasses import dataclass from pathlib import Path from typing import Any +from pyro_mcp.daily_loop import ( + DEFAULT_PREPARE_ENVIRONMENT, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + serialize_daily_loop_report, +) from pyro_mcp.vm_network import TapNetworkManager DEFAULT_PLATFORM = "linux-x86_64" @@ -200,7 +207,11 @@ def runtime_capabilities(paths: RuntimePaths) -> RuntimeCapabilities: ) -def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: +def doctor_report( + *, + platform: str = DEFAULT_PLATFORM, + environment: str = DEFAULT_PREPARE_ENVIRONMENT, +) -> dict[str, Any]: """Build a runtime diagnostics report.""" report: dict[str, Any] = { "platform": platform, @@ -258,6 +269,36 @@ def doctor_report(*, platform: str = DEFAULT_PLATFORM) -> dict[str, Any]: "cache_dir": str(environment_store.cache_dir), "environments": environment_store.list_environments(), } + environment_details = environment_store.inspect_environment(environment) + manifest_path = prepare_manifest_path( + environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, reason = evaluate_daily_loop_status( + environment=environment, + environment_version=str(environment_details["version"]), + platform=platform, + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest["bundle_version"]) + ), + installed=bool(environment_details["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + report["daily_loop"] = serialize_daily_loop_report( + environment=environment, + status=status, + installed=bool(environment_details["installed"]), + cache_dir=environment_store.cache_dir, + manifest_path=manifest_path, + reason=reason, + manifest=manifest, + ) if not report["kvm"]["exists"]: report["issues"] = ["/dev/kvm is not available on this host"] return report diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 74b1fe6..bd65b56 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "4.4.0" +DEFAULT_CATALOG_VERSION = "4.5.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", @@ -48,7 +48,7 @@ class VmEnvironment: oci_repository: str | None = None oci_reference: str | None = None source_digest: str | None = None - compatibility: str = ">=4.4.0,<5.0.0" + compatibility: str = ">=4.5.0,<5.0.0" @dataclass(frozen=True) diff --git a/src/pyro_mcp/vm_manager.py b/src/pyro_mcp/vm_manager.py index 1e99682..669b9cd 100644 --- a/src/pyro_mcp/vm_manager.py +++ b/src/pyro_mcp/vm_manager.py @@ -24,6 +24,15 @@ from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from typing import Any, Literal, cast +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, + serialize_daily_loop_report, + write_prepare_manifest, +) from pyro_mcp.runtime import ( RuntimeCapabilities, RuntimePaths, @@ -288,9 +297,7 @@ class WorkspaceRecord: network=_deserialize_network(payload.get("network")), name=_normalize_workspace_name(_optional_str(payload.get("name")), allow_none=True), labels=_normalize_workspace_labels(payload.get("labels")), - last_activity_at=float( - payload.get("last_activity_at", float(payload["created_at"])) - ), + last_activity_at=float(payload.get("last_activity_at", float(payload["created_at"]))), command_count=int(payload.get("command_count", 0)), last_command=_optional_dict(payload.get("last_command")), workspace_seed=_workspace_seed_dict(payload.get("workspace_seed")), @@ -544,9 +551,7 @@ class WorkspacePublishedPortRecord: host=str(payload.get("host", DEFAULT_PUBLISHED_PORT_HOST)), protocol=str(payload.get("protocol", "tcp")), proxy_pid=( - None - if payload.get("proxy_pid") is None - else int(payload.get("proxy_pid", 0)) + None if payload.get("proxy_pid") is None else int(payload.get("proxy_pid", 0)) ), ) @@ -921,9 +926,7 @@ def _validate_workspace_file_read_max_bytes(max_bytes: int) -> int: if max_bytes <= 0: raise ValueError("max_bytes must be positive") if max_bytes > WORKSPACE_FILE_MAX_BYTES: - raise ValueError( - f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes" - ) + raise ValueError(f"max_bytes must be at most {WORKSPACE_FILE_MAX_BYTES} bytes") return max_bytes @@ -951,9 +954,7 @@ def _decode_workspace_patch_text(path: str, content_bytes: bytes) -> str: try: return content_bytes.decode("utf-8") except UnicodeDecodeError as exc: - raise RuntimeError( - f"workspace patch only supports UTF-8 text files: {path}" - ) from exc + raise RuntimeError(f"workspace patch only supports UTF-8 text files: {path}") from exc def _normalize_archive_member_name(name: str) -> PurePosixPath: @@ -1043,9 +1044,7 @@ def _prepare_workspace_secrets( has_value = "value" in item has_file_path = "file_path" in item if has_value == has_file_path: - raise ValueError( - f"secret {name!r} must provide exactly one of 'value' or 'file_path'" - ) + raise ValueError(f"secret {name!r} must provide exactly one of 'value' or 'file_path'") source_kind: WorkspaceSecretSourceKind if has_value: value = _validate_workspace_secret_value(name, str(item["value"])) @@ -1525,9 +1524,7 @@ def _normalize_workspace_published_port_specs( ) dedupe_key = (spec.host_port, spec.guest_port) if dedupe_key in seen_guest_ports: - raise ValueError( - "published ports must not repeat the same host/guest port mapping" - ) + raise ValueError("published ports must not repeat the same host/guest port mapping") seen_guest_ports.add(dedupe_key) normalized.append(spec) return normalized @@ -1790,7 +1787,7 @@ def _start_local_service( ), "status=$?", f"printf '%s' \"$status\" > {shlex.quote(str(status_path))}", - "exit \"$status\"", + 'exit "$status"', ] ) + "\n", @@ -1973,9 +1970,7 @@ def _patch_rootfs_runtime_file( ) -> None: debugfs_path = shutil.which("debugfs") if debugfs_path is None: - raise RuntimeError( - "debugfs is required to seed workspaces on guest-backed runtimes" - ) + raise RuntimeError("debugfs is required to seed workspaces on guest-backed runtimes") with tempfile.TemporaryDirectory(prefix=f"pyro-{asset_label}-") as temp_dir: staged_path = Path(temp_dir) / Path(destination_path).name shutil.copy2(source_path, staged_path) @@ -3634,6 +3629,152 @@ class VmManager: def prune_environments(self) -> dict[str, object]: return self._environment_store.prune_environments() + def prepare_daily_loop( + self, + environment: str, + *, + network: bool = False, + force: bool = False, + ) -> dict[str, Any]: + spec = get_environment(environment, runtime_paths=self._runtime_paths) + if self._backend_name != "firecracker": + raise RuntimeError("pyro prepare requires a guest-backed runtime and is unavailable") + if not self._runtime_capabilities.supports_vm_boot: + reason = self._runtime_capabilities.reason or "runtime does not support guest boot" + raise RuntimeError( + f"pyro prepare requires guest-backed workspace boot and is unavailable: {reason}" + ) + if not self._runtime_capabilities.supports_guest_exec: + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest command execution" + ) + raise RuntimeError( + f"pyro prepare requires guest command execution and is unavailable: {reason}" + ) + if network and not self._runtime_capabilities.supports_guest_network: + reason = self._runtime_capabilities.reason or ( + "runtime does not support guest networking" + ) + raise RuntimeError( + f"pyro prepare --network requires guest networking and is unavailable: {reason}" + ) + + runtime_paths = self._runtime_paths + if runtime_paths is None: + raise RuntimeError("runtime paths are unavailable for pyro prepare") + platform = str(runtime_paths.manifest.get("platform", "linux-x86_64")) + bundle_version = cast(str | None, runtime_paths.manifest.get("bundle_version")) + manifest_path = prepare_manifest_path( + self._environment_store.cache_dir, + platform=platform, + environment=environment, + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + status, status_reason = evaluate_daily_loop_status( + environment=environment, + environment_version=spec.version, + platform=platform, + catalog_version=self._environment_store.catalog_version, + bundle_version=bundle_version, + installed=bool(self.inspect_environment(environment)["installed"]), + manifest=manifest, + manifest_error=manifest_error, + ) + if ( + not force + and status == "warm" + and prepare_request_is_satisfied(manifest, require_network=network) + ): + if manifest is None: + raise AssertionError("warm prepare state requires a manifest") + payload = serialize_daily_loop_report( + environment=environment, + status="warm", + installed=True, + cache_dir=self._environment_store.cache_dir, + manifest_path=manifest_path, + reason="reused existing warm manifest", + manifest=manifest, + ) + payload.update( + { + "prepared": True, + "reused": True, + "executed": False, + "forced": force, + "network_requested": network, + "last_prepare_duration_ms": manifest.last_prepare_duration_ms, + } + ) + return payload + + self._environment_store.ensure_installed(environment) + started = time.monotonic() + workspace_id: str | None = None + execution_mode = "pending" + try: + created = self.create_workspace( + environment=environment, + network_policy="egress" if network else "off", + allow_host_compat=False, + ) + workspace_id = str(created["workspace_id"]) + exec_result = self.exec_workspace( + workspace_id, + command="pwd", + timeout_seconds=DEFAULT_TIMEOUT_SECONDS, + ) + execution_mode = str(exec_result.get("execution_mode", "unknown")) + if int(exec_result.get("exit_code", 1)) != 0: + raise RuntimeError("prepare guest exec failed") + if str(exec_result.get("stdout", "")) != f"{WORKSPACE_GUEST_PATH}\n": + raise RuntimeError("prepare guest exec returned an unexpected working directory") + self.reset_workspace(workspace_id) + finally: + if workspace_id is not None: + try: + self.delete_workspace(workspace_id, reason="prepare_cleanup") + except Exception: + pass + + duration_ms = int((time.monotonic() - started) * 1000) + prepared_at = time.time() + preserved_network_prepared = bool( + manifest is not None and status == "warm" and manifest.network_prepared + ) + prepared_manifest = DailyLoopManifest( + environment=environment, + environment_version=spec.version, + platform=platform, + catalog_version=self._environment_store.catalog_version, + bundle_version=bundle_version, + prepared_at=prepared_at, + network_prepared=network or preserved_network_prepared, + last_prepare_duration_ms=duration_ms, + ) + write_prepare_manifest(manifest_path, prepared_manifest) + payload = serialize_daily_loop_report( + environment=environment, + status="warm", + installed=True, + cache_dir=self._environment_store.cache_dir, + manifest_path=manifest_path, + reason=status_reason, + manifest=prepared_manifest, + ) + payload.update( + { + "prepared": True, + "reused": False, + "executed": True, + "forced": force, + "network_requested": network, + "last_prepare_duration_ms": duration_ms, + "execution_mode": execution_mode, + } + ) + return payload + def create_vm( self, *, @@ -3859,9 +4000,7 @@ class VmManager: raise RuntimeError( f"max active VMs reached ({self._max_active_vms}); delete old VMs first" ) - self._require_workspace_network_policy_support( - network_policy=normalized_network_policy - ) + self._require_workspace_network_policy_support(network_policy=normalized_network_policy) self._backend.create(instance) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(instance) @@ -3963,9 +4102,7 @@ class VmManager: "destination": str(workspace_sync["destination"]), "entry_count": int(workspace_sync["entry_count"]), "bytes_written": int(workspace_sync["bytes_written"]), - "execution_mode": str( - instance.metadata.get("execution_mode", "pending") - ), + "execution_mode": str(instance.metadata.get("execution_mode", "pending")), }, ) self._save_workspace_locked(workspace) @@ -4397,9 +4534,7 @@ class VmManager: payload={ "summary": dict(summary), "entries": [dict(entry) for entry in entries[:10]], - "execution_mode": str( - instance.metadata.get("execution_mode", "pending") - ), + "execution_mode": str(instance.metadata.get("execution_mode", "pending")), }, ) self._save_workspace_locked(workspace) @@ -4568,9 +4703,7 @@ class VmManager: recreated = workspace.to_instance( workdir=self._workspace_runtime_dir(workspace.workspace_id) ) - self._require_workspace_network_policy_support( - network_policy=workspace.network_policy - ) + self._require_workspace_network_policy_support(network_policy=workspace.network_policy) self._backend.create(recreated) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(recreated) @@ -4764,9 +4897,7 @@ class VmManager: if wait_for_idle_ms is not None and ( wait_for_idle_ms <= 0 or wait_for_idle_ms > MAX_SHELL_WAIT_FOR_IDLE_MS ): - raise ValueError( - f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}" - ) + raise ValueError(f"wait_for_idle_ms must be between 1 and {MAX_SHELL_WAIT_FOR_IDLE_MS}") with self._lock: workspace = self._load_workspace_locked(workspace_id) instance = self._workspace_instance_for_live_shell_locked(workspace) @@ -5041,8 +5172,7 @@ class VmManager: if normalized_published_ports: if workspace.network_policy != "egress+published-ports": raise RuntimeError( - "published ports require workspace network_policy " - "'egress+published-ports'" + "published ports require workspace network_policy 'egress+published-ports'" ) if instance.network is None: raise RuntimeError( @@ -5447,11 +5577,7 @@ class VmManager: if not isinstance(entry, dict): continue diff_entries.append( - { - key: value - for key, value in entry.items() - if key != "text_patch" - } + {key: value for key, value in entry.items() if key != "text_patch"} ) payload["changes"] = { "available": True, @@ -5509,9 +5635,7 @@ class VmManager: self._stop_workspace_services_locked(workspace, instance) self._close_workspace_shells_locked(workspace, instance) try: - self._require_workspace_network_policy_support( - network_policy=workspace.network_policy - ) + self._require_workspace_network_policy_support(network_policy=workspace.network_policy) if self._runtime_capabilities.supports_guest_exec: self._ensure_workspace_guest_bootstrap_support(instance) with self._lock: @@ -5694,9 +5818,7 @@ class VmManager: "execution_mode": workspace.metadata.get("execution_mode", "pending"), "workspace_path": WORKSPACE_GUEST_PATH, "workspace_seed": _workspace_seed_dict(workspace.workspace_seed), - "secrets": [ - _serialize_workspace_secret_public(secret) for secret in workspace.secrets - ], + "secrets": [_serialize_workspace_secret_public(secret) for secret in workspace.secrets], "command_count": workspace.command_count, "last_command": workspace.last_command, "reset_count": workspace.reset_count, @@ -5867,9 +5989,7 @@ class VmManager: env_values: dict[str, str] = {} for secret_name, env_name in secret_env.items(): if secret_name not in secret_values: - raise ValueError( - f"secret_env references unknown workspace secret {secret_name!r}" - ) + raise ValueError(f"secret_env references unknown workspace secret {secret_name!r}") env_values[env_name] = secret_values[secret_name] return env_values @@ -6019,13 +6139,10 @@ class VmManager: bytes_written=bytes_written, cleanup_dir=cleanup_dir, ) - if ( - not resolved_source_path.is_file() - or not _is_supported_seed_archive(resolved_source_path) + if not resolved_source_path.is_file() or not _is_supported_seed_archive( + resolved_source_path ): - raise ValueError( - "seed_path must be a directory or a .tar/.tar.gz/.tgz archive" - ) + raise ValueError("seed_path must be a directory or a .tar/.tar.gz/.tgz archive") entry_count, bytes_written = _inspect_seed_archive(resolved_source_path) return PreparedWorkspaceSeed( mode="tar_archive", @@ -6128,8 +6245,7 @@ class VmManager: rootfs_path = Path(raw_rootfs_image) if not rootfs_path.exists(): raise RuntimeError( - f"workspace {workspace.workspace_id!r} rootfs image is unavailable at " - f"{rootfs_path}" + f"workspace {workspace.workspace_id!r} rootfs image is unavailable at {rootfs_path}" ) return rootfs_path @@ -6146,9 +6262,7 @@ class VmManager: f"workspace {workspace.workspace_id!r} must be stopped before {operation_name}" ) if workspace.metadata.get("execution_mode") == "host_compat": - raise RuntimeError( - f"{operation_name} is unavailable for host_compat workspaces" - ) + raise RuntimeError(f"{operation_name} is unavailable for host_compat workspaces") return self._workspace_rootfs_image_path_locked(workspace) def _scrub_workspace_runtime_state_locked( diff --git a/tests/test_cli.py b/tests/test_cli.py index 3b48e23..ecb6e74 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,14 +29,16 @@ def test_cli_help_guides_first_run() -> None: assert "Suggested zero-to-hero path:" in help_text assert "pyro doctor" in help_text - assert "pyro env list" in help_text - assert "pyro env pull debian:12" in help_text + assert "pyro prepare debian:12" in help_text assert "pyro run debian:12 -- git --version" in help_text assert "pyro host connect claude-code" in help_text assert "Connect a chat host after that:" in help_text assert "pyro host connect claude-code" in help_text assert "pyro host connect codex" in help_text assert "pyro host print-config opencode" in help_text + assert "Daily local loop after the first warmup:" in help_text + assert "pyro doctor --environment debian:12" in help_text + assert "pyro workspace reset WORKSPACE_ID" 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 summary WORKSPACE_ID" in help_text @@ -60,6 +62,12 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro env pull debian:12" in env_help assert "downloads from public Docker Hub" in env_help + prepare_help = _subparser_choice(parser, "prepare").format_help() + assert "Warm the recommended guest-backed daily loop" in prepare_help + assert "pyro prepare debian:12 --network" in prepare_help + assert "--network" in prepare_help + assert "--force" in prepare_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 @@ -87,6 +95,8 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: doctor_help = _subparser_choice(parser, "doctor").format_help() assert "Check host prerequisites and embedded runtime health" in doctor_help + assert "--environment" in doctor_help + assert "pyro doctor --environment debian:12" in doctor_help assert "pyro doctor --json" in doctor_help demo_help = _subparser_choice(parser, "demo").format_help() @@ -115,8 +125,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: assert "pyro workspace create debian:12 --name repro-fix --label issue=123" in workspace_help assert "pyro workspace list" in workspace_help assert ( - "pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" - in workspace_help + "pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex" in workspace_help ) assert "pyro workspace sync push WORKSPACE_ID ./repo --dest src" in workspace_help assert "pyro workspace exec WORKSPACE_ID" in workspace_help @@ -476,13 +485,22 @@ def test_cli_doctor_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64", json=True) + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=True, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + }, ) cli.main() output = json.loads(capsys.readouterr().out) @@ -701,7 +719,7 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None: command = cli._require_command( ["--", "sh", "-lc", 'printf "hello from workspace\\n" > note.txt'] ) - assert command == 'sh -lc \'printf "hello from workspace\\n" > note.txt\'' + assert command == "sh -lc 'printf \"hello from workspace\\n\" > note.txt'" def test_cli_read_utf8_text_file_rejects_non_utf8(tmp_path: Path) -> None: @@ -977,10 +995,7 @@ def test_cli_workspace_exec_prints_human_output( cli.main() captured = capsys.readouterr() assert captured.out == "hello\n" - assert ( - "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" - in captured.err - ) + assert "[workspace-exec] workspace_id=workspace-123 sequence=2 cwd=/workspace" in captured.err def test_print_workspace_summary_human_handles_last_command_and_secret_filtering( @@ -1451,13 +1466,7 @@ def test_cli_workspace_patch_apply_reads_patch_file( tmp_path: Path, ) -> None: patch_path = tmp_path / "fix.patch" - patch_text = ( - "--- a/src/app.py\n" - "+++ b/src/app.py\n" - "@@ -1 +1 @@\n" - "-print('hi')\n" - "+print('hello')\n" - ) + patch_text = "--- a/src/app.py\n+++ b/src/app.py\n@@ -1 +1 @@\n-print('hi')\n+print('hello')\n" patch_path.write_text(patch_text, encoding="utf-8") class StubPyro: @@ -1905,10 +1914,7 @@ def test_cli_workspace_diff_prints_human_output( monkeypatch.setattr(cli, "Pyro", StubPyro) cli.main() output = capsys.readouterr().out - assert ( - "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" - in output - ) + assert "[workspace-diff] workspace_id=workspace-123 total=1 added=0 modified=1" in output assert "--- a/note.txt" in output @@ -2355,8 +2361,7 @@ def test_cli_workspace_sync_push_prints_human( output = capsys.readouterr().out assert "[workspace-sync] workspace_id=workspace-123 mode=directory source=/tmp/repo" in output assert ( - "destination=/workspace entry_count=2 bytes_written=12 " - "execution_mode=guest_vsock" + "destination=/workspace entry_count=2 bytes_written=12 execution_mode=guest_vsock" ) in output @@ -2659,9 +2664,7 @@ def test_cli_workspace_summary_prints_human( }, "snapshots": { "named_count": 1, - "recent": [ - {"event_kind": "snapshot_create", "snapshot_name": "checkpoint"} - ], + "recent": [{"event_kind": "snapshot_create", "snapshot_name": "checkpoint"}], }, } @@ -3132,7 +3135,7 @@ def test_chat_host_docs_and_examples_recommend_modes_first() -> None: assert "--project-path /abs/path/to/repo" in readme assert "--repo-url https://github.com/example/project.git" in readme - assert "## 5. Connect a chat host" in install + assert "## 6. Connect a chat host" in install assert claude_helper in install assert codex_helper in install assert inspect_helper in install @@ -3217,6 +3220,21 @@ def test_content_only_read_docs_are_aligned() -> None: assert 'workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch' in first_run +def test_daily_loop_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + integrations = Path("docs/integrations.md").read_text(encoding="utf-8") + + assert "pyro prepare debian:12" in readme + assert "pyro prepare debian:12" in install + assert "pyro prepare debian:12" in first_run + assert "pyro prepare debian:12" in integrations + assert "pyro doctor --environment debian:12" in readme + assert "pyro doctor --environment debian:12" in install + assert "pyro doctor --environment debian:12" in first_run + + def test_workspace_summary_docs_are_aligned() -> None: readme = Path("README.md").read_text(encoding="utf-8") install = Path("docs/install.md").read_text(encoding="utf-8") @@ -4307,22 +4325,163 @@ def test_cli_doctor_prints_human( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(command="doctor", platform="linux-x86_64", json=False) + return argparse.Namespace( + command="doctor", + platform="linux-x86_64", + environment="debian:12", + json=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr( cli, "doctor_report", - lambda platform: { + lambda *, platform, environment: { "platform": platform, "runtime_ok": True, "issues": [], "kvm": {"exists": True, "readable": True, "writable": True}, + "runtime": {"catalog_version": "4.5.0", "cache_dir": "/cache"}, + "daily_loop": { + "environment": environment, + "status": "cold", + "installed": False, + "network_prepared": False, + "prepared_at": None, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "reason": "daily loop has not been prepared yet", + "cache_dir": "/cache", + }, }, ) cli.main() output = capsys.readouterr().out assert "Runtime: PASS" in output + assert "Daily loop: COLD (debian:12)" in output + assert "Run: pyro prepare debian:12" in output + + +def test_cli_prepare_prints_human( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is True + assert force is False + return { + "environment": environment, + "status": "warm", + "prepared": True, + "reused": False, + "executed": True, + "network_prepared": True, + "prepared_at": 123.0, + "manifest_path": "/cache/.prepare/linux-x86_64/debian_12.json", + "cache_dir": "/cache", + "last_prepare_duration_ms": 456, + "reason": None, + } + + class StubPyro: + def __init__(self) -> None: + self.manager = StubManager() + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=True, + force=False, + json=False, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + monkeypatch.setattr(cli, "Pyro", StubPyro) + cli.main() + output = capsys.readouterr().out + assert "Prepare: debian:12" in output + assert "Daily loop: WARM" in output + assert "Result: prepared network_prepared=yes" in output + + +def test_cli_prepare_prints_json_and_errors( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class SuccessManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + assert environment == "debian:12" + assert network is False + assert force is True + return {"environment": environment, "reused": True} + + class SuccessPyro: + def __init__(self) -> None: + self.manager = SuccessManager() + + class SuccessParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=True, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: SuccessParser()) + monkeypatch.setattr(cli, "Pyro", SuccessPyro) + cli.main() + payload = json.loads(capsys.readouterr().out) + assert payload["reused"] is True + + class ErrorManager: + def prepare_daily_loop( + self, + environment: str, + *, + network: bool, + force: bool, + ) -> dict[str, object]: + del environment, network, force + raise RuntimeError("prepare failed") + + class ErrorPyro: + def __init__(self) -> None: + self.manager = ErrorManager() + + class ErrorParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="prepare", + environment="debian:12", + network=False, + force=False, + json=True, + ) + + monkeypatch.setattr(cli, "_build_parser", lambda: ErrorParser()) + monkeypatch.setattr(cli, "Pyro", ErrorPyro) + with pytest.raises(SystemExit, match="1"): + cli.main() + error_payload = json.loads(capsys.readouterr().out) + assert error_payload["ok"] is False + assert error_payload["error"] == "prepare failed" def test_cli_run_json_error_exits_nonzero( @@ -4386,16 +4545,16 @@ def test_cli_mcp_runs_stdio_transport(monkeypatch: pytest.MonkeyPatch) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace( - command="mcp", - mcp_command="serve", - profile="workspace-core", - mode=None, - project_path="/repo", - repo_url=None, - repo_ref=None, - no_project_source=False, - ) + return argparse.Namespace( + command="mcp", + mcp_command="serve", + profile="workspace-core", + mode=None, + project_path="/repo", + repo_url=None, + repo_ref=None, + no_project_source=False, + ) monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) monkeypatch.setattr(cli, "Pyro", StubPyro) @@ -4526,7 +4685,7 @@ def test_cli_workspace_exec_passes_secret_env( class StubPyro: def exec_workspace(self, workspace_id: str, **kwargs: Any) -> dict[str, Any]: assert workspace_id == "ws-123" - assert kwargs["command"] == "sh -lc 'test \"$API_TOKEN\" = \"expected\"'" + assert kwargs["command"] == 'sh -lc \'test "$API_TOKEN" = "expected"\'' assert kwargs["secret_env"] == {"API_TOKEN": "API_TOKEN", "TOKEN": "PIP_TOKEN"} return {"exit_code": 0, "stdout": "", "stderr": ""} diff --git a/tests/test_daily_loop.py b/tests/test_daily_loop.py new file mode 100644 index 0000000..b511158 --- /dev/null +++ b/tests/test_daily_loop.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from pyro_mcp.daily_loop import ( + DailyLoopManifest, + evaluate_daily_loop_status, + load_prepare_manifest, + prepare_manifest_path, + prepare_request_is_satisfied, +) +from pyro_mcp.runtime import RuntimeCapabilities +from pyro_mcp.vm_manager import VmManager + + +def test_prepare_daily_loop_executes_then_reuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed: dict[str, object] = {} + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed["network_policy"] = kwargs["network_policy"] + return {"workspace_id": "ws-123"} + + def fake_exec_workspace( + workspace_id: str, + *, + command: str, + timeout_seconds: int = 30, + secret_env: dict[str, str] | None = None, + ) -> dict[str, object]: + observed["exec"] = { + "workspace_id": workspace_id, + "command": command, + "timeout_seconds": timeout_seconds, + "secret_env": secret_env, + } + return { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + } + + def fake_reset_workspace( + workspace_id: str, + *, + snapshot: str = "baseline", + ) -> dict[str, object]: + observed["reset"] = {"workspace_id": workspace_id, "snapshot": snapshot} + return {"workspace_id": workspace_id} + + def fake_delete_workspace( + workspace_id: str, + *, + reason: str = "explicit_delete", + ) -> dict[str, object]: + observed["delete"] = {"workspace_id": workspace_id, "reason": reason} + return {"workspace_id": workspace_id, "deleted": True} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr(manager, "exec_workspace", fake_exec_workspace) + monkeypatch.setattr(manager, "reset_workspace", fake_reset_workspace) + monkeypatch.setattr(manager, "delete_workspace", fake_delete_workspace) + + first = manager.prepare_daily_loop("debian:12") + assert first["prepared"] is True + assert first["executed"] is True + assert first["reused"] is False + assert first["network_prepared"] is False + assert first["execution_mode"] == "guest_vsock" + assert observed["network_policy"] == "off" + assert observed["exec"] == { + "workspace_id": "ws-123", + "command": "pwd", + "timeout_seconds": 30, + "secret_env": None, + } + assert observed["reset"] == {"workspace_id": "ws-123", "snapshot": "baseline"} + assert observed["delete"] == {"workspace_id": "ws-123", "reason": "prepare_cleanup"} + + second = manager.prepare_daily_loop("debian:12") + assert second["prepared"] is True + assert second["executed"] is False + assert second["reused"] is True + assert second["reason"] == "reused existing warm manifest" + + +def test_prepare_daily_loop_force_and_network_upgrade_manifest( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + monkeypatch.setattr(manager, "_backend_name", "firecracker") + monkeypatch.setattr( + manager, + "_runtime_capabilities", + RuntimeCapabilities( + supports_vm_boot=True, + supports_guest_exec=True, + supports_guest_network=True, + ), + ) + monkeypatch.setattr( + manager, + "inspect_environment", + lambda environment: {"installed": True}, + ) + monkeypatch.setattr( + manager._environment_store, + "ensure_installed", + lambda environment: object(), + ) + + observed_policies: list[str] = [] + + def fake_create_workspace(**kwargs: object) -> dict[str, object]: + observed_policies.append(str(kwargs["network_policy"])) + return {"workspace_id": "ws-1"} + + monkeypatch.setattr(manager, "create_workspace", fake_create_workspace) + monkeypatch.setattr( + manager, + "exec_workspace", + lambda workspace_id, **kwargs: { + "workspace_id": workspace_id, + "stdout": "/workspace\n", + "stderr": "", + "exit_code": 0, + "duration_ms": 1, + "execution_mode": "guest_vsock", + }, + ) + monkeypatch.setattr( + manager, + "reset_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id}, + ) + monkeypatch.setattr( + manager, + "delete_workspace", + lambda workspace_id, **kwargs: {"workspace_id": workspace_id, "deleted": True}, + ) + + manager.prepare_daily_loop("debian:12") + payload = manager.prepare_daily_loop("debian:12", network=True, force=True) + assert payload["executed"] is True + assert payload["network_prepared"] is True + assert observed_policies == ["off", "egress"] + + manifest_path = prepare_manifest_path( + tmp_path / "cache", + platform="linux-x86_64", + environment="debian:12", + ) + manifest, manifest_error = load_prepare_manifest(manifest_path) + assert manifest_error is None + if manifest is None: + raise AssertionError("expected prepare manifest") + assert manifest.network_prepared is True + + +def test_prepare_daily_loop_requires_guest_capabilities(tmp_path: Path) -> None: + manager = VmManager( + backend_name="mock", + base_dir=tmp_path / "state", + cache_dir=tmp_path / "cache", + ) + with pytest.raises(RuntimeError, match="guest-backed runtime"): + manager.prepare_daily_loop("debian:12") + + +def test_load_prepare_manifest_reports_invalid_json(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text("{broken", encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + + +def test_prepare_manifest_round_trip(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps(manifest.to_payload()), encoding="utf-8") + + loaded, error = load_prepare_manifest(manifest_path) + assert error is None + assert loaded == manifest + + +def test_load_prepare_manifest_rejects_non_object(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text('["not-an-object"]', encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error == "prepare manifest is not a JSON object" + + +def test_load_prepare_manifest_rejects_invalid_payload(tmp_path: Path) -> None: + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_path.write_text(json.dumps({"environment": "debian:12"}), encoding="utf-8") + + manifest, error = load_prepare_manifest(manifest_path) + assert manifest is None + assert error is not None + assert "prepare manifest is invalid" in error + + +def test_evaluate_daily_loop_status_edge_cases() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + manifest_error="broken manifest", + ) == ("stale", "broken manifest") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=False, + manifest=manifest, + ) == ("stale", "environment install is missing") + assert evaluate_daily_loop_status( + environment="debian:12-build", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "prepare manifest environment does not match the selected environment") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="2.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "environment version changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-aarch64", + catalog_version="4.5.0", + bundle_version="bundle-1", + installed=True, + manifest=manifest, + ) == ("stale", "platform changed since the last prepare run") + assert evaluate_daily_loop_status( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-2", + installed=True, + manifest=manifest, + ) == ("stale", "runtime bundle version changed since the last prepare run") + + +def test_prepare_request_is_satisfied_network_gate() -> None: + manifest = DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version="4.5.0", + bundle_version="bundle-1", + prepared_at=1.0, + network_prepared=False, + last_prepare_duration_ms=2, + ) + + assert prepare_request_is_satisfied(None, require_network=False) is False + assert prepare_request_is_satisfied(manifest, require_network=True) is False + assert prepare_request_is_satisfied(manifest, require_network=False) is True diff --git a/tests/test_daily_loop_smoke.py b/tests/test_daily_loop_smoke.py new file mode 100644 index 0000000..2d75fbc --- /dev/null +++ b/tests/test_daily_loop_smoke.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +import pyro_mcp.daily_loop_smoke as smoke_module + + +class _FakePyro: + def __init__(self) -> None: + self.workspace_id = "ws-1" + self.message = "broken\n" + self.deleted = False + + def create_workspace( + self, + *, + environment: str, + seed_path: Path, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> dict[str, object]: + assert environment == "debian:12" + assert seed_path.is_dir() + assert name == "daily-loop" + assert labels == {"suite": "daily-loop-smoke"} + return {"workspace_id": self.workspace_id} + + def exec_workspace(self, workspace_id: str, *, command: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + if command != "sh check.sh": + raise AssertionError(f"unexpected command: {command}") + if self.message == "fixed\n": + return {"exit_code": 0, "stdout": "fixed\n"} + return {"exit_code": 1, "stderr": "expected fixed got broken\n"} + + def apply_workspace_patch(self, workspace_id: str, *, patch: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert "+fixed" in patch + self.message = "fixed\n" + return {"changed": True} + + def export_workspace( + self, + workspace_id: str, + path: str, + *, + output_path: Path, + ) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(self.message, encoding="utf-8") + return {"artifact_type": "file"} + + def reset_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.message = "broken\n" + return {"reset_count": 1} + + def read_workspace_file(self, workspace_id: str, path: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + assert path == "message.txt" + return {"content": self.message} + + def delete_workspace(self, workspace_id: str) -> dict[str, object]: + assert workspace_id == self.workspace_id + self.deleted = True + return {"workspace_id": workspace_id, "deleted": True} + + +def test_run_prepare_parses_json(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, + stdout=json.dumps({"prepared": True}), + stderr="", + ), + ) + payload = smoke_module._run_prepare("debian:12") + assert payload == {"prepared": True} + + +def test_run_prepare_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=1, + stdout="", + stderr="prepare failed", + ), + ) + with pytest.raises(RuntimeError, match="prepare failed"): + smoke_module._run_prepare("debian:12") + + +def test_run_daily_loop_smoke_happy_path(monkeypatch: pytest.MonkeyPatch) -> None: + prepare_calls: list[str] = [] + fake_pyro = _FakePyro() + + def fake_run_prepare(environment: str) -> dict[str, object]: + prepare_calls.append(environment) + return {"prepared": True, "reused": len(prepare_calls) > 1} + + monkeypatch.setattr(smoke_module, "_run_prepare", fake_run_prepare) + monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) + + smoke_module.run_daily_loop_smoke(environment="debian:12") + + assert prepare_calls == ["debian:12", "debian:12"] + assert fake_pyro.deleted is True + + +def test_main_runs_selected_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: list[str] = [] + monkeypatch.setattr( + smoke_module, + "run_daily_loop_smoke", + lambda *, environment: observed.append(environment), + ) + monkeypatch.setattr( + smoke_module, + "build_arg_parser", + lambda: SimpleNamespace( + parse_args=lambda: SimpleNamespace(environment="debian:12-build") + ), + ) + smoke_module.main() + assert observed == ["debian:12-build"] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9629771..3c3196e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -15,13 +15,18 @@ def test_doctor_main_prints_json( ) -> None: class StubParser: def parse_args(self) -> argparse.Namespace: - return argparse.Namespace(platform="linux-x86_64") + return argparse.Namespace(platform="linux-x86_64", environment="debian:12") monkeypatch.setattr(doctor_module, "_build_parser", lambda: StubParser()) monkeypatch.setattr( doctor_module, "doctor_report", - lambda platform: {"platform": platform, "runtime_ok": True, "issues": []}, + lambda *, platform, environment: { + "platform": platform, + "environment": environment, + "runtime_ok": True, + "issues": [], + }, ) doctor_module.main() output = json.loads(capsys.readouterr().out) @@ -32,3 +37,4 @@ def test_doctor_build_parser_defaults_platform() -> None: parser = doctor_module._build_parser() args = parser.parse_args([]) assert args.platform == DEFAULT_PLATFORM + assert args.environment == "debian:12" diff --git a/tests/test_public_contract.py b/tests/test_public_contract.py index 31fbdf1..2f5385a 100644 --- a/tests/test_public_contract.py +++ b/tests/test_public_contract.py @@ -15,6 +15,7 @@ from pyro_mcp.cli import _build_parser from pyro_mcp.contract import ( PUBLIC_CLI_COMMANDS, PUBLIC_CLI_DEMO_SUBCOMMANDS, + PUBLIC_CLI_DOCTOR_FLAGS, PUBLIC_CLI_ENV_SUBCOMMANDS, PUBLIC_CLI_HOST_CONNECT_FLAGS, PUBLIC_CLI_HOST_DOCTOR_FLAGS, @@ -23,6 +24,7 @@ from pyro_mcp.contract import ( PUBLIC_CLI_HOST_SUBCOMMANDS, PUBLIC_CLI_MCP_SERVE_FLAGS, PUBLIC_CLI_MCP_SUBCOMMANDS, + PUBLIC_CLI_PREPARE_FLAGS, PUBLIC_CLI_RUN_FLAGS, PUBLIC_CLI_WORKSPACE_CREATE_FLAGS, PUBLIC_CLI_WORKSPACE_DIFF_FLAGS, @@ -113,6 +115,9 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: env_help_text = _subparser_choice(parser, "env").format_help() for subcommand_name in PUBLIC_CLI_ENV_SUBCOMMANDS: assert subcommand_name in env_help_text + prepare_help_text = _subparser_choice(parser, "prepare").format_help() + for flag in PUBLIC_CLI_PREPARE_FLAGS: + assert flag in prepare_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 @@ -136,6 +141,9 @@ def test_public_cli_help_lists_commands_and_run_flags() -> None: ).format_help() for flag in PUBLIC_CLI_HOST_REPAIR_FLAGS: assert flag in host_repair_help_text + doctor_help_text = _subparser_choice(parser, "doctor").format_help() + for flag in PUBLIC_CLI_DOCTOR_FLAGS: + assert flag in doctor_help_text mcp_help_text = _subparser_choice(parser, "mcp").format_help() for subcommand_name in PUBLIC_CLI_MCP_SUBCOMMANDS: assert subcommand_name in mcp_help_text diff --git a/tests/test_runtime.py b/tests/test_runtime.py index a2b9004..60ebc4b 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -6,7 +6,32 @@ from pathlib import Path import pytest +from pyro_mcp.daily_loop import DailyLoopManifest, prepare_manifest_path, write_prepare_manifest from pyro_mcp.runtime import doctor_report, resolve_runtime_paths, runtime_capabilities +from pyro_mcp.vm_environments import EnvironmentStore, get_environment + + +def _materialize_installed_environment( + environment_store: EnvironmentStore, + *, + name: str, +) -> None: + spec = get_environment(name, runtime_paths=environment_store._runtime_paths) + install_dir = environment_store._install_dir(spec) + install_dir.mkdir(parents=True, exist_ok=True) + (install_dir / "vmlinux").write_text("kernel\n", encoding="utf-8") + (install_dir / "rootfs.ext4").write_text("rootfs\n", encoding="utf-8") + (install_dir / "environment.json").write_text( + json.dumps( + { + "name": spec.name, + "version": spec.version, + "source": "test-cache", + "source_digest": spec.source_digest, + } + ), + encoding="utf-8", + ) def test_resolve_runtime_paths_default_bundle() -> None: @@ -109,6 +134,7 @@ def test_doctor_report_has_runtime_fields() -> None: assert "runtime_ok" in report assert "kvm" in report assert "networking" in report + assert "daily_loop" in report if report["runtime_ok"]: runtime = report.get("runtime") assert isinstance(runtime, dict) @@ -122,6 +148,61 @@ def test_doctor_report_has_runtime_fields() -> None: assert "tun_available" in networking +def test_doctor_report_daily_loop_statuses( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("PYRO_ENVIRONMENT_CACHE_DIR", str(tmp_path)) + cold_report = doctor_report(environment="debian:12") + cold_daily_loop = cold_report["daily_loop"] + assert cold_daily_loop["status"] == "cold" + assert cold_daily_loop["installed"] is False + + paths = resolve_runtime_paths() + environment_store = EnvironmentStore(runtime_paths=paths, cache_dir=tmp_path) + _materialize_installed_environment(environment_store, name="debian:12") + + installed_report = doctor_report(environment="debian:12") + installed_daily_loop = installed_report["daily_loop"] + assert installed_daily_loop["status"] == "cold" + assert installed_daily_loop["installed"] is True + + manifest_path = prepare_manifest_path( + tmp_path, + platform="linux-x86_64", + environment="debian:12", + ) + write_prepare_manifest( + manifest_path, + DailyLoopManifest( + environment="debian:12", + environment_version="1.0.0", + platform="linux-x86_64", + catalog_version=environment_store.catalog_version, + bundle_version=( + None + if paths.manifest.get("bundle_version") is None + else str(paths.manifest.get("bundle_version")) + ), + prepared_at=123.0, + network_prepared=True, + last_prepare_duration_ms=456, + ), + ) + warm_report = doctor_report(environment="debian:12") + warm_daily_loop = warm_report["daily_loop"] + assert warm_daily_loop["status"] == "warm" + assert warm_daily_loop["network_prepared"] is True + + stale_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + stale_manifest["catalog_version"] = "0.0.0" + manifest_path.write_text(json.dumps(stale_manifest), encoding="utf-8") + stale_report = doctor_report(environment="debian:12") + stale_daily_loop = stale_report["daily_loop"] + assert stale_daily_loop["status"] == "stale" + assert "catalog version changed" in str(stale_daily_loop["reason"]) + + def test_runtime_capabilities_reports_real_bundle_flags() -> None: paths = resolve_runtime_paths() capabilities = runtime_capabilities(paths) diff --git a/uv.lock b/uv.lock index b554f16..14f3147 100644 --- a/uv.lock +++ b/uv.lock @@ -715,7 +715,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "4.4.0" +version = "4.5.0" source = { editable = "." } dependencies = [ { name = "mcp" }, From aeed5e19431c7b523ae0f99bc4eae5d9b00eda42 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 14 Mar 2026 11:18:48 -0300 Subject: [PATCH 8/8] add milestones --- docs/roadmap/llm-chat-ergonomics.md | 47 +++++++++++++--- .../4.6.0-git-tracked-project-sources.md | 55 +++++++++++++++++++ ...project-source-diagnostics-and-recovery.md | 50 +++++++++++++++++ ...-first-class-chat-environment-selection.md | 52 ++++++++++++++++++ .../4.9.0-real-repo-qualification-smokes.md | 52 ++++++++++++++++++ ...5.0.0-whole-project-sandbox-development.md | 53 ++++++++++++++++++ 6 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md create mode 100644 docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md create mode 100644 docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 229d3da..440f528 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -36,16 +36,16 @@ More concretely, the model should not need to: - choose from an unnecessarily large tool surface when a smaller profile would work -The next gaps for the narrowed persona are now product-shaping rather than raw -capability gaps: +The next gaps for the narrowed persona are now about real-project credibility: -- starting from the current repo still needs to feel native from the first chat -- host setup and repair still lean on manual commands and config copying -- reviewing what the agent actually did still requires digging through several - surfaces -- the five use cases exist as docs and smokes, not yet as explicit product - modes -- daily reset and retry loops can still feel heavier than they should +- current-checkout startup is still brittle for messy local repos with unreadable, + generated, or permission-sensitive files +- the guest-backed smoke pack is strong, but it still proves shaped scenarios + better than arbitrary local-repo readiness +- the chat-host path still does not let users choose the sandbox environment as + a first-class part of host connection and server startup +- the product should not claim full whole-project development readiness until it + qualifies a real-project loop beyond fixture-shaped use cases ## Locked Decisions @@ -61,6 +61,14 @@ capability gaps: structured behavior natively - prioritize repo-aware startup, trust, and daily-loop speed before adding more low-level workspace surface area +- for repo-root auto-detection and `--project-path` inside a Git checkout, the + default project source should become Git-tracked files only +- `--repo-url` remains the clean-clone path when users do not want to trust the + local checkout as the startup source +- environment selection must become first-class in the chat-host path before the + product claims whole-project development readiness +- real-project readiness must be proven with guest-backed qualification smokes + that cover ignored, generated, and unreadable-file cases - breaking changes are acceptable while there are still no users and the chat-host product is still being shaped - every milestone below must also update docs, help text, runnable examples, @@ -84,6 +92,11 @@ capability gaps: 14. [`4.3.0` Reviewable Agent Output](llm-chat-ergonomics/4.3.0-reviewable-agent-output.md) - Done 15. [`4.4.0` Opinionated Use-Case Modes](llm-chat-ergonomics/4.4.0-opinionated-use-case-modes.md) - Done 16. [`4.5.0` Faster Daily Loops](llm-chat-ergonomics/4.5.0-faster-daily-loops.md) - Done +17. [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) - Planned +18. [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) - Planned +19. [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) - Planned +20. [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) - Planned +21. [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) - Planned Completed so far: @@ -130,6 +143,14 @@ Completed so far: real `make smoke-daily-loop` verification path so the local machine warmup story is explicit before the chat host connects. +Planned next: + +- [`4.6.0` Git-Tracked Project Sources](llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md) +- [`4.7.0` Project Source Diagnostics And Recovery](llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md) +- [`4.8.0` First-Class Chat Environment Selection](llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md) +- [`4.9.0` Real-Repo Qualification Smokes](llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md) +- [`5.0.0` Whole-Project Sandbox Development](llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md) + ## Expected Outcome After this roadmap, the product should still look like an agent workspace, not @@ -155,3 +176,11 @@ The intended model-facing shape is: - users can review one concise summary of what the agent changed and ran - the main workflows feel like named modes instead of one giant reference - reset and retry loops are fast enough to encourage daily use +- repo-root startup is robust even when the local checkout contains ignored, + generated, or unreadable files +- chat-host users can choose the sandbox environment as part of the normal + connect/start path +- the product has guest-backed qualification for real local repos, not only + shaped fixture scenarios +- it becomes credible to tell a user they can develop a real project inside + sandboxes, not just evaluate or patch one diff --git a/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md new file mode 100644 index 0000000..b170b21 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.6.0-git-tracked-project-sources.md @@ -0,0 +1,55 @@ +# `4.6.0` Git-Tracked Project Sources + +Status: Planned + +## Goal + +Make repo-root startup and `--project-path` robust for messy real checkouts by +stopping the default chat-host path from trying to ingest every readable and +unreadable file in the working tree. + +## Public API Changes + +Project-aware startup should change its default local source semantics: + +- bare `pyro mcp serve` from inside a Git checkout should seed from Git-tracked + files only +- `pyro mcp serve --project-path PATH` should also use Git-tracked files only + when `PATH` is inside a Git checkout +- `--repo-url` remains the clean-clone path when the user wants a host-side + clone instead of the local checkout +- explicit `workspace create --seed-path PATH` remains unchanged in this + milestone + +## Implementation Boundaries + +- apply the new semantics only to project-aware startup sources, not every + explicit directory seed +- do not silently include ignored or untracked junk in the default chat-host + path +- preserve explicit diff, export, sync push, and reset behavior +- surface the chosen project source clearly enough that users know what the + sandbox started from + +## Non-Goals + +- no generic SCM abstraction layer +- no silent live sync between the host checkout and the guest +- no change to explicit archive seeding semantics in this milestone + +## Acceptance Scenarios + +- starting `pyro mcp serve` from a repo root no longer fails on unreadable + build artifacts or ignored runtime byproducts +- starting from `--project-path` inside a Git repo behaves the same way +- users can predict that the startup source matches the tracked project state + rather than the entire working tree + +## Required Repo Updates + +- README, install docs, integrations docs, and public contract updated to state + what local project-aware startup actually includes +- help text updated to distinguish project-aware startup from explicit + `--seed-path` behavior +- at least one guest-backed smoke scenario added for a repo with ignored, + generated, and unreadable files diff --git a/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md new file mode 100644 index 0000000..a758e79 --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.7.0-project-source-diagnostics-and-recovery.md @@ -0,0 +1,50 @@ +# `4.7.0` Project Source Diagnostics And Recovery + +Status: Planned + +## Goal + +Make project-source selection and startup failures understandable enough that a +chat-host user can recover without reading internals or raw tracebacks. + +## Public API Changes + +The chat-host path should expose clearer project-source diagnostics: + +- `pyro doctor` should report the active project-source kind and its readiness +- `pyro mcp serve` and host helpers should explain whether they are using + tracked local files, `--project-path`, `--repo-url`, or no project source +- startup failures should recommend the right fallback: + `--project-path`, `--repo-url`, `--no-project-source`, or explicit + `seed_path` + +## Implementation Boundaries + +- keep diagnostics focused on the chat-host path rather than inventing a broad + source-management subsystem +- prefer actionable recovery guidance over long implementation detail dumps +- make project-source diagnostics visible from the same surfaces users already + touch: help text, `doctor`, host helpers, and startup errors + +## Non-Goals + +- no generic repo-health audit product +- no attempt to auto-fix arbitrary local checkout corruption +- no host-specific divergence in project-source behavior + +## Acceptance Scenarios + +- a user can tell which project source the chat host will use before creating a + workspace +- a user who hits a project-source failure gets a concrete recovery path instead + of a raw permission traceback +- host helper doctor and repair flows can explain project-source problems, not + only MCP config problems + +## Required Repo Updates + +- docs, help text, and troubleshooting updated with project-source diagnostics + and fallback guidance +- at least one smoke or targeted CLI test covering the new failure guidance +- host-helper docs updated to show when to prefer `--project-path`, + `--repo-url`, or `--no-project-source` diff --git a/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md new file mode 100644 index 0000000..fcb80eb --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.8.0-first-class-chat-environment-selection.md @@ -0,0 +1,52 @@ +# `4.8.0` First-Class Chat Environment Selection + +Status: Planned + +## Goal + +Make curated environment choice part of the normal chat-host path so full +project work is not implicitly tied to one default environment. + +## Public API Changes + +Environment selection should become first-class in the chat-host path: + +- `pyro mcp serve` should accept an explicit environment +- `pyro host connect` should accept and preserve an explicit environment +- `pyro host print-config` and `pyro host repair` should preserve the selected + environment where relevant +- named modes should be able to recommend a default environment when one is + better for the workflow, without removing explicit user choice + +## Implementation Boundaries + +- keep environment selection aligned with the existing curated environment + catalog +- avoid inventing host-specific environment behavior for the same mode +- keep the default environment path simple for the quickest evaluator flow +- ensure the chosen environment is visible from generated config, help text, and + diagnostics + +## Non-Goals + +- no custom user-built environment pipeline in this milestone +- no per-host environment negotiation logic +- no attempt to solve arbitrary dependency installation through environment + sprawl alone + +## Acceptance Scenarios + +- a user can choose a build-oriented environment such as `debian:12-build` + before connecting the chat host +- host helpers, raw server startup, and printed configs all preserve the same + environment choice +- docs can teach whole-project development without pretending every project fits + the same default environment + +## Required Repo Updates + +- README, install docs, integrations docs, public contract, and host examples + updated to show environment selection in the chat-host path +- help text updated for raw server startup and host helpers +- at least one guest-backed smoke scenario updated to prove a non-default + environment in the chat-host flow diff --git a/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md new file mode 100644 index 0000000..04bea7c --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/4.9.0-real-repo-qualification-smokes.md @@ -0,0 +1,52 @@ +# `4.9.0` Real-Repo Qualification Smokes + +Status: Planned + +## Goal + +Replace fixture-only confidence with guest-backed proof that the chat-host path +works against messy local repos and clean-clone startup sources. + +## Public API Changes + +No new runtime surface is required in this milestone. The main additions are +qualification smokes and their supporting fixtures. + +The new coverage should prove: + +- repo-root startup from a local Git checkout with ignored, generated, and + unreadable files +- `--repo-url` clean-clone startup +- a realistic install, test, patch, rerun, and export loop +- at least one nontrivial service-start or readiness loop + +## Implementation Boundaries + +- keep the smoke pack guest-backed and deterministic enough to use as a release + gate +- focus on realistic repo-shape and project-loop problems, not synthetic + micro-feature assertions +- prefer a small number of representative project fixtures over a large matrix + of toy repos + +## Non-Goals + +- no promise to qualify every language ecosystem in one milestone +- no cloud or remote execution qualification layer +- no broad benchmark suite beyond what is needed to prove readiness + +## Acceptance Scenarios + +- the repo has one clear smoke target for real-repo qualification +- at least one local-checkout smoke proves the new Git-tracked startup behavior +- at least one clean-clone smoke proves the `--repo-url` path +- failures in these smokes clearly separate project-source issues from runtime + or host issues + +## Required Repo Updates + +- new guest-backed smoke targets and any supporting fixtures +- roadmap, use-case docs, and release/readiness docs updated to point at the + new qualification path +- troubleshooting updated with the distinction between shaped use-case smokes + and real-repo qualification smokes diff --git a/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md new file mode 100644 index 0000000..ba2195a --- /dev/null +++ b/docs/roadmap/llm-chat-ergonomics/5.0.0-whole-project-sandbox-development.md @@ -0,0 +1,53 @@ +# `5.0.0` Whole-Project Sandbox Development + +Status: Planned + +## Goal + +Reach the point where it is credible to tell a user they can develop a real +project inside sandboxes, not just validate, inspect, or patch one. + +## Public API Changes + +No new generic VM breadth is required here. This milestone should consolidate +the earlier pieces into one believable full-project product story: + +- robust project-aware startup +- explicit environment selection in the chat-host path +- summaries, reset, export, and service workflows that hold up during longer + work loops +- qualification targets that prove a nontrivial development cycle + +## Implementation Boundaries + +- keep the product centered on the chat-host workspace path rather than a broad + CLI or SDK platform story +- use the existing named modes and generic workspace path where they fit, but + teach one end-to-end full-project development walkthrough +- prioritize daily development credibility over adding new low-level sandbox + surfaces + +## Non-Goals + +- no attempt to become a generic remote dev environment platform +- no scheduler, queue, or CI matrix abstractions +- no claim that every possible project type is equally well supported + +## Acceptance Scenarios + +- the docs contain one end-to-end “develop a project in sandboxes” walkthrough +- that walkthrough covers dependency install, tests, patching, reruns, review, + and export, with app/service startup when relevant +- at least one guest-backed qualification target proves the story on a + nontrivial project +- the readiness docs can honestly say whole-project development is supported + with explicit caveats instead of hedged aspirational language + +## Required Repo Updates + +- README, install docs, integrations docs, use-case docs, and public contract + updated to include the whole-project development story +- at least one walkthrough asset or transcript added for the new end-to-end + path +- readiness and troubleshooting docs updated with the actual supported scope and + remaining caveats