Add workspace handoff shortcuts and file-backed inputs

Remove the remaining shell glue from the canonical CLI workspace flows so users can hand off IDs and host-authored text files directly.

Add --id-only on workspace create and shell open, plus --text-file and --patch-file for workspace file write and patch apply, while keeping the underlying SDK, MCP, and backend behavior unchanged.

Update the top walkthroughs, contract docs, roadmap status, and use-case smoke runner to use the new shortcuts, and verify the milestone with uv lock, make check, make dist-check, focused CLI tests, and a real guest-backed smoke for create, file write, patch apply, and shell open/read.
This commit is contained in:
Thales Maciel 2026-03-13 11:10:11 -03:00
parent 788fc4fad4
commit 7a0620fc0c
15 changed files with 466 additions and 79 deletions

View file

@ -2,6 +2,19 @@
All notable user-visible changes to `pyro-mcp` are documented here. All notable user-visible changes to `pyro-mcp` are documented here.
## 3.7.0
- Added CLI handoff shortcuts with `pyro workspace create --id-only` and
`pyro workspace shell open --id-only` so shell scripts and walkthroughs can
capture identifiers without JSON parsing glue.
- Added file-backed text inputs for `pyro workspace file write --text-file` and
`pyro workspace patch apply --patch-file`, keeping the existing `--text` and
`--patch` behavior stable while removing `$(cat ...)` shell expansion from
the canonical flows.
- Rewrote the top workspace walkthroughs, CLI help examples, and roadmap/docs
around the new shortcut flags, and updated the real guest-backed repro/fix
smoke to exercise a file-backed patch input through the CLI.
## 3.6.0 ## 3.6.0
- Added `docs/use-cases/` with five concrete workspace recipes for cold-start validation, - Added `docs/use-cases/` with five concrete workspace recipes for cold-start validation,

View file

@ -23,7 +23,7 @@ It exposes the same runtime in three public forms:
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif) - Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif) - Terminal walkthrough GIF: [docs/assets/first-run.gif](docs/assets/first-run.gif)
- PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/) - PyPI package: [pypi.org/project/pyro-mcp](https://pypi.org/project/pyro-mcp/)
- What's new in 3.6.0: [CHANGELOG.md#360](CHANGELOG.md#360) - What's new in 3.7.0: [CHANGELOG.md#370](CHANGELOG.md#370)
- Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md)
- Integration targets: [docs/integrations.md](docs/integrations.md) - Integration targets: [docs/integrations.md](docs/integrations.md)
- Public contract: [docs/public-contract.md](docs/public-contract.md) - Public contract: [docs/public-contract.md](docs/public-contract.md)
@ -60,7 +60,7 @@ What success looks like:
```bash ```bash
Platform: linux-x86_64 Platform: linux-x86_64
Runtime: PASS Runtime: PASS
Catalog version: 3.6.0 Catalog version: 3.7.0
... ...
[pull] phase=install environment=debian:12 [pull] phase=install environment=debian:12
[pull] phase=ready environment=debian:12 [pull] phase=ready environment=debian:12
@ -92,12 +92,12 @@ for the published package, or `uv run pyro ...` from a source checkout.
```bash ```bash
uv tool install pyro-mcp uv tool install pyro-mcp
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --json | python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])')" WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
pyro workspace list pyro workspace list
pyro workspace update "$WORKSPACE_ID" --label owner=codex pyro workspace update "$WORKSPACE_ID" --label owner=codex
pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt pyro workspace file read "$WORKSPACE_ID" note.txt
pyro workspace patch apply "$WORKSPACE_ID" --patch "$(cat fix.patch)" pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint 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 service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
@ -132,12 +132,12 @@ After the quickstart works:
- enable outbound guest networking for one workspace with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress` - 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` - 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` - 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`, `uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"` - inspect and edit files without shell quoting with `uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py`, `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` - 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` - 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` - 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` - 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` - 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'` - 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` - 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` - stop a workspace for offline inspection with `uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID`
@ -195,7 +195,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```bash
Catalog version: 3.6.0 Catalog version: 3.7.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -277,8 +277,8 @@ pyro workspace create debian:12 --network-policy egress+published-ports
pyro workspace sync push WORKSPACE_ID ./changes --dest src pyro workspace sync push WORKSPACE_ID ./changes --dest src
pyro workspace file list WORKSPACE_ID src --recursive pyro workspace file list WORKSPACE_ID src --recursive
pyro workspace file read WORKSPACE_ID src/note.txt pyro workspace file read WORKSPACE_ID src/note.txt
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")' pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)" pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
pyro workspace exec WORKSPACE_ID -- cat src/note.txt 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 exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
pyro workspace diff WORKSPACE_ID pyro workspace diff WORKSPACE_ID
@ -286,7 +286,7 @@ pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace reset WORKSPACE_ID pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN 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 write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
pyro workspace shell close WORKSPACE_ID SHELL_ID pyro workspace shell close WORKSPACE_ID SHELL_ID
@ -308,10 +308,11 @@ pyro workspace delete WORKSPACE_ID
``` ```
Persistent workspaces start in `/workspace` and keep command history until you delete them. For Persistent workspaces start in `/workspace` and keep command history until you delete them. For
machine consumption, add `--json` and read the returned `workspace_id`. Use `--seed-path` when 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` 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 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 `3.6.0`; if it fails later host-side changes into a started workspace. Sync is non-atomic in `3.7.0`; if it fails
partway through, prefer `pyro workspace reset` to recover from `baseline` or one named snapshot. 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 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 baseline, and `pyro workspace export` to copy one changed file or directory back to the host. Use

View file

@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
```bash ```bash
$ uvx --from pyro-mcp pyro env list $ uvx --from pyro-mcp pyro env list
Catalog version: 3.6.0 Catalog version: 3.7.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -73,13 +73,12 @@ installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `u
a source checkout. a source checkout.
```bash ```bash
$ uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --json | tee /tmp/pyro-workspace.json $ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
$ export WORKSPACE_ID="$(python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])' < /tmp/pyro-workspace.json)"
$ uvx --from pyro-mcp pyro workspace list $ 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 update "$WORKSPACE_ID" --label owner=codex
$ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes $ uvx --from pyro-mcp pyro workspace sync push "$WORKSPACE_ID" ./changes
$ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt $ uvx --from pyro-mcp pyro workspace file read "$WORKSPACE_ID" note.txt
$ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch "$(cat fix.patch)" $ 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 exec "$WORKSPACE_ID" -- cat note.txt
$ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint $ 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 service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
@ -103,8 +102,8 @@ $ 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 sync push WORKSPACE_ID ./changes
$ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive $ 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 $ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py
$ uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")' $ 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 "$(cat fix.patch)" $ 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 --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 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 exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
@ -112,7 +111,7 @@ $ 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 snapshot create WORKSPACE_ID checkpoint
$ uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot 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 export WORKSPACE_ID note.txt --output ./note.txt
$ uvx --from pyro-mcp pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN $ 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 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 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 workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app
@ -180,7 +179,7 @@ Reset count: 1
$ uvx --from pyro-mcp pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt $ 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 [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 $ 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 [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' $ uvx --from pyro-mcp pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'
@ -256,7 +255,7 @@ State: started
Use `--seed-path` when the workspace should start from a host directory or a local 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 `.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 `pyro workspace sync push` when you need to import later host-side changes into a started
workspace. Sync is non-atomic in `3.6.0`; if it fails partway through, prefer `pyro workspace reset` workspace. Sync is non-atomic in `3.7.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 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 `/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 named checkpoints, and `pyro workspace export` to copy one changed file or directory back to the

View file

@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
Expected output: Expected output:
```bash ```bash
Catalog version: 3.6.0 Catalog version: 3.7.0
debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows.
debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling.
debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled.
@ -140,12 +140,12 @@ for the published package, or `uv run pyro ...` from a source checkout.
```bash ```bash
uv tool install pyro-mcp uv tool install pyro-mcp
WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --json | python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])')" WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro-fix --label issue=123 --id-only)"
pyro workspace list pyro workspace list
pyro workspace update "$WORKSPACE_ID" --label owner=codex pyro workspace update "$WORKSPACE_ID" --label owner=codex
pyro workspace sync push "$WORKSPACE_ID" ./changes pyro workspace sync push "$WORKSPACE_ID" ./changes
pyro workspace file read "$WORKSPACE_ID" note.txt pyro workspace file read "$WORKSPACE_ID" note.txt
pyro workspace patch apply "$WORKSPACE_ID" --patch "$(cat fix.patch)" pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch
pyro workspace exec "$WORKSPACE_ID" -- cat note.txt pyro workspace exec "$WORKSPACE_ID" -- cat note.txt
pyro workspace snapshot create "$WORKSPACE_ID" checkpoint 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 service start "$WORKSPACE_ID" web --ready-file .web-ready -- sh -lc 'touch .web-ready && while true; do sleep 60; done'
@ -221,12 +221,12 @@ After the CLI path works, you can move on to:
- live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes` - live workspace updates: `pyro workspace sync push WORKSPACE_ID ./changes`
- guest networking policy: `pyro workspace create debian:12 --network-policy egress` - 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` - 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`, `pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'`, and `pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"` - model-native file editing: `pyro workspace file read WORKSPACE_ID src/app.py`, `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` - 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` - 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` - 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`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` - stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4`
- interactive shells: `pyro workspace shell open WORKSPACE_ID` - 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'` - 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` - 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 --profile workspace-core` - MCP: `pyro mcp serve --profile workspace-core`
@ -245,8 +245,8 @@ pyro workspace create debian:12 --network-policy egress+published-ports
pyro workspace sync push WORKSPACE_ID ./changes --dest src pyro workspace sync push WORKSPACE_ID ./changes --dest src
pyro workspace file list WORKSPACE_ID src --recursive pyro workspace file list WORKSPACE_ID src --recursive
pyro workspace file read WORKSPACE_ID src/note.txt pyro workspace file read WORKSPACE_ID src/note.txt
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")' pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)" pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
pyro workspace exec WORKSPACE_ID -- cat src/note.txt 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 exec WORKSPACE_ID --secret-env API_TOKEN -- sh -lc 'test "$API_TOKEN" = "expected"'
pyro workspace diff WORKSPACE_ID pyro workspace diff WORKSPACE_ID
@ -254,7 +254,7 @@ pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace reset WORKSPACE_ID pyro workspace reset WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID --secret-env API_TOKEN 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 write WORKSPACE_ID SHELL_ID --input 'pwd'
pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300 pyro workspace shell read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
pyro workspace shell close WORKSPACE_ID SHELL_ID pyro workspace shell close WORKSPACE_ID SHELL_ID
@ -276,10 +276,11 @@ pyro workspace delete WORKSPACE_ID
``` ```
Workspace commands default to the persistent `/workspace` directory inside the guest. If you need Workspace commands default to the persistent `/workspace` directory inside the guest. If you need
the identifier programmatically, use `--json` and read the `workspace_id` field. Use `--seed-path` 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` 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 archive. Use `pyro workspace sync push` for later host-side changes to a started workspace. Sync
is non-atomic in `3.6.0`; if it fails partway through, prefer `pyro workspace reset` to recover is non-atomic in `3.7.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 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 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 checkpoints, and `pyro workspace export` to copy one changed file or directory back to the host. Use

View file

@ -80,6 +80,7 @@ Behavioral guarantees:
- `pyro demo ollama` prints log lines plus a final summary line. - `pyro demo ollama` prints log lines plus a final summary line.
- `pyro workspace create` auto-starts a persistent workspace. - `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 --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 --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 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. - `pyro mcp serve --profile {vm-run,workspace-core,workspace-full}` narrows the model-facing MCP surface without changing runtime behavior.
@ -90,7 +91,7 @@ Behavioral guarantees:
- `pyro workspace start WORKSPACE_ID` restarts one stopped workspace without resetting `/workspace`. - `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 list WORKSPACE_ID [PATH] [--recursive]` returns metadata for one live path under `/workspace`.
- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N]` reads one regular text file under `/workspace`. - `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N]` reads one regular text file under `/workspace`.
- `pyro workspace file write WORKSPACE_ID PATH --text TEXT` creates or replaces one regular text file under `/workspace`, creating missing parent directories automatically. - `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 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 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 list WORKSPACE_ID [PATH] [--recursive]` inspects a stopped guest-backed workspace rootfs offline without booting the guest.
@ -104,7 +105,8 @@ Behavioral guarantees:
- `pyro workspace exec --secret-env SECRET_NAME[=ENV_VAR]` maps one persisted secret into one exec call. - `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 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 exec` runs in the persistent `/workspace` for that workspace and does not auto-clean.
- `pyro workspace patch apply WORKSPACE_ID --patch TEXT` applies one unified text patch with add/modify/delete operations under `/workspace`. - `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 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 *` 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 shell read --plain --wait-for-idle-ms 300` is the recommended chat-facing read mode; raw shell reads remain available without `--plain`.

View file

@ -6,7 +6,7 @@ goal:
make the core agent-workspace use cases feel trivial from a chat-driven LLM make the core agent-workspace use cases feel trivial from a chat-driven LLM
interface. interface.
Current baseline is `3.6.0`: Current baseline is `3.7.0`:
- the stable workspace contract exists across CLI, SDK, and MCP - the stable workspace contract exists across CLI, SDK, and MCP
- one-shot `pyro run` still exists as the narrow entrypoint - one-shot `pyro run` still exists as the narrow entrypoint
@ -37,8 +37,8 @@ The remaining UX friction for a technically strong new user is now narrower:
- the best chat-host profile is recommended in docs, but not yet obvious enough - the best chat-host profile is recommended in docs, but not yet obvious enough
from the default live `mcp serve` path from the default live `mcp serve` path
- canonical CLI walkthroughs still need small amounts of shell glue such as - canonical CLI walkthroughs are cleaner now, but the recommended chat-host
`python -c` extraction of `workspace_id` and `$(cat fix.patch)` expansion entrypoint still needs to be more obvious from the default docs and help
- human-mode file reads are functional, but still need final transcript polish - human-mode file reads are functional, but still need final transcript polish
for copy-paste and chat logs for copy-paste and chat logs
@ -61,7 +61,7 @@ The remaining UX friction for a technically strong new user is now narrower:
3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done 3. [`3.4.0` Tool Profiles And Canonical Chat Flows](llm-chat-ergonomics/3.4.0-tool-profiles-and-canonical-chat-flows.md) - Done
4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) - Done 4. [`3.5.0` Chat-Friendly Shell Output](llm-chat-ergonomics/3.5.0-chat-friendly-shell-output.md) - Done
5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) - Done 5. [`3.6.0` Use-Case Recipes And Smoke Packs](llm-chat-ergonomics/3.6.0-use-case-recipes-and-smoke-packs.md) - Done
6. [`3.7.0` Handoff Shortcuts And File Input Sources](llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md) - Planned 6. [`3.7.0` Handoff Shortcuts And File Input Sources](llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md) - Done
7. [`3.8.0` Chat-Host Onramp And Recommended Defaults](llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md) - Planned 7. [`3.8.0` Chat-Host Onramp And Recommended Defaults](llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md) - Planned
8. [`3.9.0` Content-Only Reads And Human Output Polish](llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md) - Planned 8. [`3.9.0` Content-Only Reads And Human Output Polish](llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md) - Planned
@ -80,11 +80,12 @@ Completed so far:
- `3.6.0` added recipe docs and real guest-backed smoke packs for the five core workspace use - `3.6.0` added recipe docs and real guest-backed smoke packs for the five core workspace use
cases so the stable product is now demonstrated as repeatable end-to-end stories instead of cases so the stable product is now demonstrated as repeatable end-to-end stories instead of
only isolated feature surfaces. only isolated feature surfaces.
- `3.7.0` removed the remaining shell glue from canonical CLI workspace flows with `--id-only`,
`--text-file`, and `--patch-file`, so the shortest handoff path no longer depends on `python -c`
extraction or `$(cat ...)` expansion.
Planned next: Planned next:
- `3.7.0` removes the remaining shell glue from canonical CLI flows with shortcut flags for
identifier handoff and file-backed text inputs.
- `3.8.0` makes the recommended chat-host entrypoint obvious from the top-level docs, help text, - `3.8.0` makes the recommended chat-host entrypoint obvious from the top-level docs, help text,
and shipped MCP examples without changing the `3.x` compatibility default. and shipped MCP examples without changing the `3.x` compatibility default.
- `3.9.0` makes human-mode file reads cleaner in terminals and chat logs, with explicit - `3.9.0` makes human-mode file reads cleaner in terminals and chat logs, with explicit

View file

@ -1,6 +1,6 @@
# `3.7.0` Handoff Shortcuts And File Input Sources # `3.7.0` Handoff Shortcuts And File Input Sources
Status: Planned Status: Done
## Goal ## Goal

View file

@ -1,6 +1,6 @@
[project] [project]
name = "pyro-mcp" name = "pyro-mcp"
version = "3.6.0" version = "3.7.0"
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents." description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }

View file

@ -6,6 +6,7 @@ import argparse
import json import json
import shlex import shlex
import sys import sys
from pathlib import Path
from textwrap import dedent from textwrap import dedent
from typing import Any from typing import Any
@ -33,6 +34,10 @@ def _print_json(payload: dict[str, Any]) -> None:
print(json.dumps(payload, indent=2, sort_keys=True)) print(json.dumps(payload, indent=2, sort_keys=True))
def _print_id_only(value: object) -> None:
print(str(value), flush=True)
def _write_stream(text: str, *, stream: Any) -> None: def _write_stream(text: str, *, stream: Any) -> None:
if text == "": if text == "":
return return
@ -632,13 +637,13 @@ def _build_parser() -> argparse.ArgumentParser:
pyro run debian:12 -- git --version pyro run debian:12 -- git --version
Continue into the stable workspace path after that: Continue into the stable workspace path after that:
pyro workspace create debian:12 --seed-path ./repo pyro workspace create debian:12 --seed-path ./repo --id-only
pyro workspace sync push WORKSPACE_ID ./changes pyro workspace sync push WORKSPACE_ID ./changes
pyro workspace exec WORKSPACE_ID -- cat note.txt pyro workspace exec WORKSPACE_ID -- cat note.txt
pyro workspace diff WORKSPACE_ID pyro workspace diff WORKSPACE_ID
pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot create WORKSPACE_ID checkpoint
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace shell open WORKSPACE_ID pyro workspace shell open WORKSPACE_ID --id-only
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done' sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace export WORKSPACE_ID note.txt --output ./note.txt pyro workspace export WORKSPACE_ID note.txt --output ./note.txt
@ -867,13 +872,13 @@ def _build_parser() -> argparse.ArgumentParser:
epilog=dedent( epilog=dedent(
""" """
Examples: Examples:
pyro workspace create debian:12 --seed-path ./repo pyro workspace create debian:12 --seed-path ./repo --id-only
pyro workspace create debian:12 --name repro-fix --label issue=123 pyro workspace create debian:12 --name repro-fix --label issue=123
pyro workspace list pyro workspace list
pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex
pyro workspace sync push WORKSPACE_ID ./repo --dest src pyro workspace sync push WORKSPACE_ID ./repo --dest src
pyro workspace file read WORKSPACE_ID src/app.py pyro workspace file read WORKSPACE_ID src/app.py
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)" pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt' pyro workspace exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt'
pyro workspace stop WORKSPACE_ID pyro workspace stop WORKSPACE_ID
pyro workspace disk list WORKSPACE_ID pyro workspace disk list WORKSPACE_ID
@ -883,7 +888,7 @@ def _build_parser() -> argparse.ArgumentParser:
pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint
pyro workspace diff WORKSPACE_ID pyro workspace diff WORKSPACE_ID
pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt
pyro workspace shell open WORKSPACE_ID pyro workspace shell open WORKSPACE_ID --id-only
pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \
sh -lc 'touch .ready && while true; do sleep 60; done' sh -lc 'touch .ready && while true; do sleep 60; done'
pyro workspace logs WORKSPACE_ID pyro workspace logs WORKSPACE_ID
@ -909,8 +914,8 @@ def _build_parser() -> argparse.ArgumentParser:
epilog=dedent( epilog=dedent(
""" """
Examples: Examples:
pyro workspace create debian:12 pyro workspace create debian:12 --id-only
pyro workspace create debian:12 --seed-path ./repo pyro workspace create debian:12 --seed-path ./repo --id-only
pyro workspace create debian:12 --name repro-fix --label issue=123 pyro workspace create debian:12 --name repro-fix --label issue=123
pyro workspace create debian:12 --network-policy egress pyro workspace create debian:12 --network-policy egress
pyro workspace create debian:12 --secret API_TOKEN=expected pyro workspace create debian:12 --secret API_TOKEN=expected
@ -995,11 +1000,17 @@ def _build_parser() -> argparse.ArgumentParser:
metavar="NAME=PATH", metavar="NAME=PATH",
help="Persist one UTF-8 secret copied from a host file at create time.", help="Persist one UTF-8 secret copied from a host file at create time.",
) )
workspace_create_parser.add_argument( workspace_create_output_group = workspace_create_parser.add_mutually_exclusive_group()
workspace_create_output_group.add_argument(
"--json", "--json",
action="store_true", action="store_true",
help="Print structured JSON instead of human-readable output.", help="Print structured JSON instead of human-readable output.",
) )
workspace_create_output_group.add_argument(
"--id-only",
action="store_true",
help="Print only the new workspace identifier.",
)
workspace_exec_parser = workspace_subparsers.add_parser( workspace_exec_parser = workspace_subparsers.add_parser(
"exec", "exec",
help="Run one command inside an existing workspace.", help="Run one command inside an existing workspace.",
@ -1163,7 +1174,7 @@ def _build_parser() -> argparse.ArgumentParser:
Examples: Examples:
pyro workspace file list WORKSPACE_ID pyro workspace file list WORKSPACE_ID
pyro workspace file read WORKSPACE_ID src/app.py pyro workspace file read WORKSPACE_ID src/app.py
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")' pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py
""" """
), ),
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
@ -1230,17 +1241,24 @@ def _build_parser() -> argparse.ArgumentParser:
), ),
epilog=( epilog=(
"Example:\n" "Example:\n"
" pyro workspace file write WORKSPACE_ID src/app.py --text 'print(\"hi\")'" " pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py"
), ),
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
) )
workspace_file_write_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_file_write_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_file_write_parser.add_argument("path", metavar="PATH") workspace_file_write_parser.add_argument("path", metavar="PATH")
workspace_file_write_parser.add_argument( workspace_file_write_input_group = workspace_file_write_parser.add_mutually_exclusive_group(
required=True
)
workspace_file_write_input_group.add_argument(
"--text", "--text",
required=True,
help="UTF-8 text content to write into the target file.", help="UTF-8 text content to write into the target file.",
) )
workspace_file_write_input_group.add_argument(
"--text-file",
metavar="PATH",
help="Read UTF-8 text content from a host file.",
)
workspace_file_write_parser.add_argument( workspace_file_write_parser.add_argument(
"--json", "--json",
action="store_true", action="store_true",
@ -1256,7 +1274,7 @@ def _build_parser() -> argparse.ArgumentParser:
epilog=dedent( epilog=dedent(
""" """
Example: Example:
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)" pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch
Patch application is preflighted but not fully transactional. If an apply fails Patch application is preflighted but not fully transactional. If an apply fails
partway through, prefer `pyro workspace reset WORKSPACE_ID`. partway through, prefer `pyro workspace reset WORKSPACE_ID`.
@ -1276,15 +1294,22 @@ def _build_parser() -> argparse.ArgumentParser:
"Apply one unified text patch for add, modify, and delete operations under " "Apply one unified text patch for add, modify, and delete operations under "
"`/workspace`." "`/workspace`."
), ),
epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch \"$(cat fix.patch)\"", epilog="Example:\n pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch",
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
) )
workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID")
workspace_patch_apply_parser.add_argument( workspace_patch_input_group = workspace_patch_apply_parser.add_mutually_exclusive_group(
required=True
)
workspace_patch_input_group.add_argument(
"--patch", "--patch",
required=True,
help="Unified text patch to apply under `/workspace`.", help="Unified text patch to apply under `/workspace`.",
) )
workspace_patch_input_group.add_argument(
"--patch-file",
metavar="PATH",
help="Read a unified text patch from a UTF-8 host file.",
)
workspace_patch_apply_parser.add_argument( workspace_patch_apply_parser.add_argument(
"--json", "--json",
action="store_true", action="store_true",
@ -1529,7 +1554,7 @@ def _build_parser() -> argparse.ArgumentParser:
epilog=dedent( epilog=dedent(
""" """
Examples: Examples:
pyro workspace shell open WORKSPACE_ID pyro workspace shell open WORKSPACE_ID --id-only
pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' 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 read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT
@ -1550,7 +1575,7 @@ def _build_parser() -> argparse.ArgumentParser:
"open", "open",
help="Open a persistent interactive shell.", help="Open a persistent interactive shell.",
description="Open a new PTY shell inside a started workspace.", description="Open a new PTY shell inside a started workspace.",
epilog="Example:\n pyro workspace shell open WORKSPACE_ID --cwd src", epilog="Example:\n pyro workspace shell open WORKSPACE_ID --cwd src --id-only",
formatter_class=_HelpFormatter, formatter_class=_HelpFormatter,
) )
workspace_shell_open_parser.add_argument( workspace_shell_open_parser.add_argument(
@ -1582,11 +1607,17 @@ def _build_parser() -> argparse.ArgumentParser:
metavar="SECRET[=ENV_VAR]", metavar="SECRET[=ENV_VAR]",
help="Expose one persisted workspace secret as an environment variable in the shell.", help="Expose one persisted workspace secret as an environment variable in the shell.",
) )
workspace_shell_open_parser.add_argument( workspace_shell_open_output_group = workspace_shell_open_parser.add_mutually_exclusive_group()
workspace_shell_open_output_group.add_argument(
"--json", "--json",
action="store_true", action="store_true",
help="Print structured JSON instead of human-readable output.", help="Print structured JSON instead of human-readable output.",
) )
workspace_shell_open_output_group.add_argument(
"--id-only",
action="store_true",
help="Print only the new shell identifier.",
)
workspace_shell_read_parser = workspace_shell_subparsers.add_parser( workspace_shell_read_parser = workspace_shell_subparsers.add_parser(
"read", "read",
help="Read merged PTY output from a shell.", help="Read merged PTY output from a shell.",
@ -2092,6 +2123,20 @@ def _require_command(command_args: list[str]) -> str:
return shlex.join(command_args) return shlex.join(command_args)
def _read_utf8_text_file(path_value: str, *, option_name: str) -> str:
if path_value.strip() == "":
raise ValueError(f"{option_name} must not be empty")
candidate = Path(path_value).expanduser()
if not candidate.exists():
raise ValueError(f"{option_name} file not found: {candidate}")
if candidate.is_dir():
raise ValueError(f"{option_name} must point to a file, not a directory: {candidate}")
try:
return candidate.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise ValueError(f"{option_name} must contain UTF-8 text: {candidate}") from exc
def _parse_workspace_secret_option(value: str) -> dict[str, str]: def _parse_workspace_secret_option(value: str) -> dict[str, str]:
name, sep, secret_value = value.partition("=") name, sep, secret_value = value.partition("=")
if sep == "" or name.strip() == "" or secret_value == "": if sep == "" or name.strip() == "" or secret_value == "":
@ -2285,7 +2330,9 @@ def main() -> None:
name=args.name, name=args.name,
labels=labels or None, labels=labels or None,
) )
if bool(args.json): if bool(getattr(args, "id_only", False)):
_print_id_only(payload["workspace_id"])
elif bool(args.json):
_print_json(payload) _print_json(payload)
else: else:
_print_workspace_summary_human(payload, action="Workspace") _print_workspace_summary_human(payload, action="Workspace")
@ -2454,11 +2501,16 @@ def main() -> None:
_print_workspace_file_read_human(payload) _print_workspace_file_read_human(payload)
return return
if args.workspace_file_command == "write": if args.workspace_file_command == "write":
text = (
args.text
if getattr(args, "text", None) is not None
else _read_utf8_text_file(args.text_file, option_name="--text-file")
)
try: try:
payload = pyro.write_workspace_file( payload = pyro.write_workspace_file(
args.workspace_id, args.workspace_id,
args.path, args.path,
text=args.text, text=text,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
if bool(args.json): if bool(args.json):
@ -2472,10 +2524,15 @@ def main() -> None:
_print_workspace_file_write_human(payload) _print_workspace_file_write_human(payload)
return return
if args.workspace_command == "patch" and args.workspace_patch_command == "apply": if args.workspace_command == "patch" and args.workspace_patch_command == "apply":
patch_text = (
args.patch
if getattr(args, "patch", None) is not None
else _read_utf8_text_file(args.patch_file, option_name="--patch-file")
)
try: try:
payload = pyro.apply_workspace_patch( payload = pyro.apply_workspace_patch(
args.workspace_id, args.workspace_id,
patch=args.patch, patch=patch_text,
) )
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
if bool(args.json): if bool(args.json):
@ -2653,7 +2710,9 @@ def main() -> None:
else: else:
print(f"[error] {exc}", file=sys.stderr, flush=True) print(f"[error] {exc}", file=sys.stderr, flush=True)
raise SystemExit(1) from exc raise SystemExit(1) from exc
if bool(args.json): if bool(getattr(args, "id_only", False)):
_print_id_only(payload["shell_id"])
elif bool(args.json):
_print_json(payload) _print_json(payload)
else: else:
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-open") _print_workspace_shell_summary_human(payload, prefix="workspace-shell-open")

View file

@ -47,6 +47,7 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
"--secret", "--secret",
"--secret-file", "--secret-file",
"--json", "--json",
"--id-only",
) )
PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json") PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json") PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json")
@ -56,9 +57,9 @@ PUBLIC_CLI_WORKSPACE_DIFF_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json") PUBLIC_CLI_WORKSPACE_EXPORT_FLAGS = ("--output", "--json")
PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json") PUBLIC_CLI_WORKSPACE_FILE_LIST_FLAGS = ("--recursive", "--json")
PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--json") PUBLIC_CLI_WORKSPACE_FILE_READ_FLAGS = ("--max-bytes", "--json")
PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--json") PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--text-file", "--json")
PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--json") PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--patch-file", "--json")
PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json") PUBLIC_CLI_WORKSPACE_RESET_FLAGS = ("--snapshot", "--json")
PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_SERVICE_LIST_FLAGS = ("--json",)
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json") PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json")
@ -82,6 +83,7 @@ PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
"--rows", "--rows",
"--secret-env", "--secret-env",
"--json", "--json",
"--id-only",
) )
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = ( PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
"--cursor", "--cursor",

View file

@ -19,7 +19,7 @@ from typing import Any
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
DEFAULT_ENVIRONMENT_VERSION = "1.0.0" DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
DEFAULT_CATALOG_VERSION = "3.6.0" DEFAULT_CATALOG_VERSION = "3.7.0"
OCI_MANIFEST_ACCEPT = ", ".join( OCI_MANIFEST_ACCEPT = ", ".join(
( (
"application/vnd.oci.image.index.v1+json", "application/vnd.oci.image.index.v1+json",

View file

@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import subprocess
import sys
import tempfile import tempfile
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@ -107,6 +109,17 @@ def _log(message: str) -> None:
print(f"[smoke] {message}", flush=True) print(f"[smoke] {message}", flush=True)
def _run_pyro_cli(*args: str, cwd: Path) -> str:
completed = subprocess.run(
[sys.executable, "-m", "pyro_mcp.cli", *args],
cwd=str(cwd),
check=True,
capture_output=True,
text=True,
)
return completed.stdout
def _create_workspace( def _create_workspace(
pyro: Pyro, pyro: Pyro,
*, *,
@ -198,6 +211,7 @@ def _scenario_cold_start_validation(pyro: Pyro, *, root: Path, environment: str)
def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> None: def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> None:
seed_dir = root / "seed" seed_dir = root / "seed"
export_dir = root / "export" export_dir = root / "export"
patch_path = root / "fix.patch"
_write_text(seed_dir / "message.txt", "broken\n") _write_text(seed_dir / "message.txt", "broken\n")
_write_text( _write_text(
seed_dir / "check.sh", seed_dir / "check.sh",
@ -210,6 +224,14 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
"}\n" "}\n"
"printf '%s\\n' \"$value\"\n", "printf '%s\\n' \"$value\"\n",
) )
_write_text(
patch_path,
"--- a/message.txt\n"
"+++ b/message.txt\n"
"@@ -1 +1 @@\n"
"-broken\n"
"+fixed\n",
)
workspace_id: str | None = None workspace_id: str | None = None
try: try:
workspace_id = _create_workspace( workspace_id = _create_workspace(
@ -224,17 +246,16 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
assert str(initial_read["content"]) == "broken\n", initial_read assert str(initial_read["content"]) == "broken\n", initial_read
failing = pyro.exec_workspace(workspace_id, command="sh check.sh") failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
assert int(failing["exit_code"]) != 0, failing assert int(failing["exit_code"]) != 0, failing
patch = pyro.apply_workspace_patch( patch_output = _run_pyro_cli(
"workspace",
"patch",
"apply",
workspace_id, workspace_id,
patch=( "--patch-file",
"--- a/message.txt\n" str(patch_path),
"+++ b/message.txt\n" cwd=root,
"@@ -1 +1 @@\n"
"-broken\n"
"+fixed\n"
),
) )
assert bool(patch["changed"]) is True, patch assert "[workspace-patch] workspace_id=" in patch_output, patch_output
passing = pyro.exec_workspace(workspace_id, command="sh check.sh") passing = pyro.exec_workspace(workspace_id, command="sh check.sh")
assert int(passing["exit_code"]) == 0, passing assert int(passing["exit_code"]) == 0, passing
assert str(passing["stdout"]) == "fixed\n", passing assert str(passing["stdout"]) == "fixed\n", passing

View file

@ -72,6 +72,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
workspace_help = _subparser_choice(parser, "workspace").format_help() workspace_help = _subparser_choice(parser, "workspace").format_help()
assert "stable workspace contract" in workspace_help assert "stable workspace contract" in workspace_help
assert "pyro workspace create debian:12 --seed-path ./repo" 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 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 list" in workspace_help
assert ( assert (
@ -88,12 +89,13 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
assert "pyro workspace start WORKSPACE_ID" in workspace_help assert "pyro workspace start WORKSPACE_ID" in workspace_help
assert "pyro workspace snapshot create WORKSPACE_ID checkpoint" 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 reset WORKSPACE_ID --snapshot checkpoint" in workspace_help
assert "pyro workspace shell open WORKSPACE_ID" in workspace_help assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_help
workspace_create_help = _subparser_choice( workspace_create_help = _subparser_choice(
_subparser_choice(parser, "workspace"), _subparser_choice(parser, "workspace"),
"create", "create",
).format_help() ).format_help()
assert "--id-only" in workspace_create_help
assert "--name" in workspace_create_help assert "--name" in workspace_create_help
assert "--label" in workspace_create_help assert "--label" in workspace_create_help
assert "--seed-path" in workspace_create_help assert "--seed-path" in workspace_create_help
@ -161,6 +163,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write" _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write"
).format_help() ).format_help()
assert "--text" in workspace_file_write_help assert "--text" in workspace_file_write_help
assert "--text-file" in workspace_file_write_help
workspace_patch_help = _subparser_choice( workspace_patch_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "patch" _subparser_choice(parser, "workspace"), "patch"
@ -171,6 +174,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(_subparser_choice(parser, "workspace"), "patch"), "apply" _subparser_choice(_subparser_choice(parser, "workspace"), "patch"), "apply"
).format_help() ).format_help()
assert "--patch" in workspace_patch_apply_help assert "--patch" in workspace_patch_apply_help
assert "--patch-file" in workspace_patch_apply_help
workspace_stop_help = _subparser_choice( workspace_stop_help = _subparser_choice(
_subparser_choice(parser, "workspace"), "stop" _subparser_choice(parser, "workspace"), "stop"
@ -241,7 +245,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
_subparser_choice(parser, "workspace"), _subparser_choice(parser, "workspace"),
"shell", "shell",
).format_help() ).format_help()
assert "pyro workspace shell open WORKSPACE_ID" in workspace_shell_help assert "pyro workspace shell open WORKSPACE_ID --id-only" in workspace_shell_help
assert "Use `workspace exec` for one-shot commands." in workspace_shell_help assert "Use `workspace exec` for one-shot commands." in workspace_shell_help
workspace_service_help = _subparser_choice( workspace_service_help = _subparser_choice(
@ -269,6 +273,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
workspace_shell_open_help = _subparser_choice( workspace_shell_open_help = _subparser_choice(
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open" _subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open"
).format_help() ).format_help()
assert "--id-only" in workspace_shell_open_help
assert "--cwd" in workspace_shell_open_help assert "--cwd" in workspace_shell_open_help
assert "--cols" in workspace_shell_open_help assert "--cols" in workspace_shell_open_help
assert "--rows" in workspace_shell_open_help assert "--rows" in workspace_shell_open_help
@ -563,6 +568,75 @@ def test_cli_requires_command_preserves_shell_argument_boundaries() -> None:
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:
source_path = tmp_path / "bad.txt"
source_path.write_bytes(b"\xff\xfe")
with pytest.raises(ValueError, match="must contain UTF-8 text"):
cli._read_utf8_text_file(str(source_path), option_name="--text-file")
def test_cli_read_utf8_text_file_rejects_empty_path() -> None:
with pytest.raises(ValueError, match="must not be empty"):
cli._read_utf8_text_file("", option_name="--patch-file")
def test_cli_shortcut_flags_are_mutually_exclusive() -> None:
parser = cli._build_parser()
with pytest.raises(SystemExit):
parser.parse_args(
[
"workspace",
"create",
"debian:12",
"--json",
"--id-only",
]
)
with pytest.raises(SystemExit):
parser.parse_args(
[
"workspace",
"shell",
"open",
"workspace-123",
"--json",
"--id-only",
]
)
with pytest.raises(SystemExit):
parser.parse_args(
[
"workspace",
"file",
"write",
"workspace-123",
"src/app.py",
"--text",
"hello",
"--text-file",
"./app.py",
]
)
with pytest.raises(SystemExit):
parser.parse_args(
[
"workspace",
"patch",
"apply",
"workspace-123",
"--patch",
"--- a/app.py\n+++ b/app.py\n",
"--patch-file",
"./fix.patch",
]
)
def test_cli_workspace_create_prints_json( def test_cli_workspace_create_prints_json(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
@ -601,6 +675,42 @@ def test_cli_workspace_create_prints_json(
assert output["workspace_id"] == "workspace-123" assert output["workspace_id"] == "workspace-123"
def test_cli_workspace_create_prints_id_only(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
class StubPyro:
def create_workspace(self, **kwargs: Any) -> dict[str, Any]:
assert kwargs["environment"] == "debian:12"
return {"workspace_id": "workspace-123", "state": "started"}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="create",
environment="debian:12",
vcpu_count=1,
mem_mib=1024,
ttl_seconds=600,
network_policy="off",
allow_host_compat=False,
seed_path=None,
name=None,
label=[],
secret=[],
secret_file=[],
json=False,
id_only=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
captured = capsys.readouterr()
assert captured.out == "workspace-123\n"
assert captured.err == ""
def test_cli_workspace_create_prints_human( def test_cli_workspace_create_prints_human(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:
@ -1126,6 +1236,105 @@ def test_cli_workspace_file_commands_print_human_and_json(
assert "[workspace-patch] workspace_id=workspace-123 total=1" in patch_output assert "[workspace-patch] workspace_id=workspace-123 total=1" in patch_output
def test_cli_workspace_file_write_reads_text_file(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
source_path = tmp_path / "app.py"
source_path.write_text("print('from file')\n", encoding="utf-8")
class StubPyro:
def write_workspace_file(
self,
workspace_id: str,
path: str,
*,
text: str,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert path == "src/app.py"
assert text == "print('from file')\n"
return {
"workspace_id": workspace_id,
"path": "/workspace/src/app.py",
"size_bytes": len(text.encode("utf-8")),
"bytes_written": len(text.encode("utf-8")),
"execution_mode": "guest_vsock",
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="file",
workspace_file_command="write",
workspace_id="workspace-123",
path="src/app.py",
text=None,
text_file=str(source_path),
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "[workspace-file-write] workspace_id=workspace-123" in output
def test_cli_workspace_patch_apply_reads_patch_file(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
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_path.write_text(patch_text, encoding="utf-8")
class StubPyro:
def apply_workspace_patch(
self,
workspace_id: str,
*,
patch: str,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert patch == patch_text
return {
"workspace_id": workspace_id,
"changed": True,
"summary": {"total": 1, "added": 0, "modified": 1, "deleted": 0},
"entries": [{"path": "/workspace/src/app.py", "status": "modified"}],
"patch": patch,
"execution_mode": "guest_vsock",
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="patch",
workspace_patch_command="apply",
workspace_id="workspace-123",
patch=None,
patch_file=str(patch_path),
json=False,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
output = capsys.readouterr().out
assert "[workspace-patch] workspace_id=workspace-123 total=1" in output
def test_cli_workspace_stop_and_start_print_human_output( def test_cli_workspace_stop_and_start_print_human_output(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],
@ -2333,6 +2542,61 @@ def test_cli_workspace_shell_open_and_read_human(
assert "wait_for_idle_ms=300" in captured.err assert "wait_for_idle_ms=300" in captured.err
def test_cli_workspace_shell_open_prints_id_only(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
class StubPyro:
def open_shell(
self,
workspace_id: str,
*,
cwd: str,
cols: int,
rows: int,
secret_env: dict[str, str] | None = None,
) -> dict[str, Any]:
assert workspace_id == "workspace-123"
assert cwd == "/workspace"
assert cols == 120
assert rows == 30
assert secret_env is None
return {
"workspace_id": workspace_id,
"shell_id": "shell-123",
"state": "running",
"cwd": cwd,
"cols": cols,
"rows": rows,
"started_at": 1.0,
"ended_at": None,
"exit_code": None,
"execution_mode": "guest_vsock",
}
class StubParser:
def parse_args(self) -> argparse.Namespace:
return argparse.Namespace(
command="workspace",
workspace_command="shell",
workspace_shell_command="open",
workspace_id="workspace-123",
cwd="/workspace",
cols=120,
rows=30,
secret_env=[],
json=False,
id_only=True,
)
monkeypatch.setattr(cli, "_build_parser", lambda: StubParser())
monkeypatch.setattr(cli, "Pyro", StubPyro)
cli.main()
captured = capsys.readouterr()
assert captured.out == "shell-123\n"
assert captured.err == ""
def test_cli_workspace_shell_write_signal_close_json( def test_cli_workspace_shell_write_signal_close_json(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str], capsys: pytest.CaptureFixture[str],

View file

@ -478,6 +478,18 @@ def test_run_all_use_case_scenarios_with_fake_pyro(
fake_pyro = _FakePyro(tmp_path / "fake-pyro") fake_pyro = _FakePyro(tmp_path / "fake-pyro")
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
monkeypatch.setattr(time_module, "sleep", lambda _seconds: None) monkeypatch.setattr(time_module, "sleep", lambda _seconds: None)
monkeypatch.setattr(
smoke_module,
"_run_pyro_cli",
lambda *args, cwd: (
fake_pyro.write_workspace_file(
args[3],
"message.txt",
text="fixed\n",
),
f"[workspace-patch] workspace_id={args[3]} total=1\n",
)[1],
)
smoke_module.run_workspace_use_case_scenario("all") smoke_module.run_workspace_use_case_scenario("all")
@ -493,6 +505,18 @@ def test_main_runs_selected_scenario(monkeypatch: pytest.MonkeyPatch, tmp_path:
fake_pyro = _FakePyro(tmp_path / "fake-pyro-main") fake_pyro = _FakePyro(tmp_path / "fake-pyro-main")
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro) monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
monkeypatch.setattr(time_module, "sleep", lambda _seconds: None) monkeypatch.setattr(time_module, "sleep", lambda _seconds: None)
monkeypatch.setattr(
smoke_module,
"_run_pyro_cli",
lambda *args, cwd: (
fake_pyro.write_workspace_file(
args[3],
"message.txt",
text="fixed\n",
),
f"[workspace-patch] workspace_id={args[3]} total=1\n",
)[1],
)
monkeypatch.setattr( monkeypatch.setattr(
"sys.argv", "sys.argv",
[ [

2
uv.lock generated
View file

@ -706,7 +706,7 @@ crypto = [
[[package]] [[package]]
name = "pyro-mcp" name = "pyro-mcp"
version = "3.6.0" version = "3.7.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "mcp" }, { name = "mcp" },