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:
parent
788fc4fad4
commit
7a0620fc0c
15 changed files with 466 additions and 79 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
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
|
||||
|
||||
- Added `docs/use-cases/` with five concrete workspace recipes for cold-start validation,
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -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)
|
||||
- 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 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)
|
||||
- Integration targets: [docs/integrations.md](docs/integrations.md)
|
||||
- Public contract: [docs/public-contract.md](docs/public-contract.md)
|
||||
|
|
@ -60,7 +60,7 @@ What success looks like:
|
|||
```bash
|
||||
Platform: linux-x86_64
|
||||
Runtime: PASS
|
||||
Catalog version: 3.6.0
|
||||
Catalog version: 3.7.0
|
||||
...
|
||||
[pull] phase=install 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
|
||||
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 update "$WORKSPACE_ID" --label owner=codex
|
||||
pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
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 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'
|
||||
|
|
@ -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`
|
||||
- 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`, `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`
|
||||
- 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`
|
||||
- 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`
|
||||
|
|
@ -195,7 +195,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```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-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.
|
||||
|
|
@ -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 file list WORKSPACE_ID src --recursive
|
||||
pyro workspace file read WORKSPACE_ID src/note.txt
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'
|
||||
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
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
|
||||
|
|
@ -286,7 +286,7 @@ 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
|
||||
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
|
||||
|
|
@ -308,10 +308,11 @@ pyro workspace delete WORKSPACE_ID
|
|||
```
|
||||
|
||||
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`
|
||||
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.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Networking: tun=yes ip_forward=yes
|
|||
|
||||
```bash
|
||||
$ 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-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.
|
||||
|
|
@ -73,13 +73,12 @@ installed `pyro` binary by dropping the `uvx --from pyro-mcp` prefix, or with `u
|
|||
a source checkout.
|
||||
|
||||
```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="$(python -c 'import json,sys; print(json.load(sys.stdin)["workspace_id"])' < /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)"
|
||||
$ 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
|
||||
$ 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 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'
|
||||
|
|
@ -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 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 write WORKSPACE_ID src/app.py --text 'print("hi")'
|
||||
$ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
$ 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"'
|
||||
|
|
@ -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 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
|
||||
$ 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
|
||||
|
|
@ -180,7 +179,7 @@ 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
|
||||
$ 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'
|
||||
|
|
@ -256,7 +255,7 @@ 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 `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
|
||||
`/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
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ uvx --from pyro-mcp pyro env list
|
|||
Expected output:
|
||||
|
||||
```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-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,12 +140,12 @@ 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 --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 update "$WORKSPACE_ID" --label owner=codex
|
||||
pyro workspace sync push "$WORKSPACE_ID" ./changes
|
||||
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 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'
|
||||
|
|
@ -221,12 +221,12 @@ After the CLI path works, you can move on to:
|
|||
- 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`, `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`
|
||||
- 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`, 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'`
|
||||
- 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`
|
||||
|
|
@ -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 file list WORKSPACE_ID src --recursive
|
||||
pyro workspace file read WORKSPACE_ID src/note.txt
|
||||
pyro workspace file write WORKSPACE_ID src/app.py --text 'print("hi")'
|
||||
pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)"
|
||||
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
|
||||
|
|
@ -254,7 +254,7 @@ 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
|
||||
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
|
||||
|
|
@ -276,10 +276,11 @@ pyro workspace delete WORKSPACE_ID
|
|||
```
|
||||
|
||||
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`
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ Behavioral guarantees:
|
|||
- `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.
|
||||
|
|
@ -90,7 +91,7 @@ Behavioral guarantees:
|
|||
- `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]` 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 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.
|
||||
|
|
@ -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 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` 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 *` 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`.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ goal:
|
|||
make the core agent-workspace use cases feel trivial from a chat-driven LLM
|
||||
interface.
|
||||
|
||||
Current baseline is `3.6.0`:
|
||||
Current baseline is `3.7.0`:
|
||||
|
||||
- the stable workspace contract exists across CLI, SDK, and MCP
|
||||
- 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
|
||||
from the default live `mcp serve` path
|
||||
- canonical CLI walkthroughs still need small amounts of shell glue such as
|
||||
`python -c` extraction of `workspace_id` and `$(cat fix.patch)` expansion
|
||||
- canonical CLI walkthroughs are cleaner now, but the recommended chat-host
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
cases so the stable product is now demonstrated as repeatable end-to-end stories instead of
|
||||
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:
|
||||
|
||||
- `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,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# `3.7.0` Handoff Shortcuts And File Input Sources
|
||||
|
||||
Status: Planned
|
||||
Status: Done
|
||||
|
||||
## Goal
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "pyro-mcp"
|
||||
version = "3.6.0"
|
||||
version = "3.7.0"
|
||||
description = "Stable Firecracker workspaces, one-shot sandboxes, and MCP tools for coding agents."
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import argparse
|
|||
import json
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
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))
|
||||
|
||||
|
||||
def _print_id_only(value: object) -> None:
|
||||
print(str(value), flush=True)
|
||||
|
||||
|
||||
def _write_stream(text: str, *, stream: Any) -> None:
|
||||
if text == "":
|
||||
return
|
||||
|
|
@ -632,13 +637,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro run debian:12 -- git --version
|
||||
|
||||
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 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
|
||||
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
|
||||
|
|
@ -867,13 +872,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
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 list
|
||||
pyro workspace update WORKSPACE_ID --name retry-run --label owner=codex
|
||||
pyro workspace sync push WORKSPACE_ID ./repo --dest src
|
||||
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 stop 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 diff WORKSPACE_ID
|
||||
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 -- \
|
||||
sh -lc 'touch .ready && while true; do sleep 60; done'
|
||||
pyro workspace logs WORKSPACE_ID
|
||||
|
|
@ -909,8 +914,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro workspace create debian:12
|
||||
pyro workspace create debian:12 --seed-path ./repo
|
||||
pyro workspace create debian:12 --id-only
|
||||
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 --network-policy egress
|
||||
pyro workspace create debian:12 --secret API_TOKEN=expected
|
||||
|
|
@ -995,11 +1000,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
metavar="NAME=PATH",
|
||||
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",
|
||||
action="store_true",
|
||||
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(
|
||||
"exec",
|
||||
help="Run one command inside an existing workspace.",
|
||||
|
|
@ -1163,7 +1174,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
Examples:
|
||||
pyro workspace file list WORKSPACE_ID
|
||||
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,
|
||||
|
|
@ -1230,17 +1241,24 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
),
|
||||
epilog=(
|
||||
"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,
|
||||
)
|
||||
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(
|
||||
workspace_file_write_input_group = workspace_file_write_parser.add_mutually_exclusive_group(
|
||||
required=True
|
||||
)
|
||||
workspace_file_write_input_group.add_argument(
|
||||
"--text",
|
||||
required=True,
|
||||
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(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1256,7 +1274,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
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
|
||||
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 "
|
||||
"`/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,
|
||||
)
|
||||
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",
|
||||
required=True,
|
||||
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(
|
||||
"--json",
|
||||
action="store_true",
|
||||
|
|
@ -1529,7 +1554,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
epilog=dedent(
|
||||
"""
|
||||
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 read WORKSPACE_ID SHELL_ID --plain --wait-for-idle-ms 300
|
||||
pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT
|
||||
|
|
@ -1550,7 +1575,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"open",
|
||||
help="Open a persistent interactive shell.",
|
||||
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,
|
||||
)
|
||||
workspace_shell_open_parser.add_argument(
|
||||
|
|
@ -1582,11 +1607,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
metavar="SECRET[=ENV_VAR]",
|
||||
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",
|
||||
action="store_true",
|
||||
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(
|
||||
"read",
|
||||
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)
|
||||
|
||||
|
||||
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]:
|
||||
name, sep, secret_value = value.partition("=")
|
||||
if sep == "" or name.strip() == "" or secret_value == "":
|
||||
|
|
@ -2285,7 +2330,9 @@ def main() -> None:
|
|||
name=args.name,
|
||||
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)
|
||||
else:
|
||||
_print_workspace_summary_human(payload, action="Workspace")
|
||||
|
|
@ -2454,11 +2501,16 @@ def main() -> None:
|
|||
_print_workspace_file_read_human(payload)
|
||||
return
|
||||
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:
|
||||
payload = pyro.write_workspace_file(
|
||||
args.workspace_id,
|
||||
args.path,
|
||||
text=args.text,
|
||||
text=text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
@ -2472,10 +2524,15 @@ def main() -> None:
|
|||
_print_workspace_file_write_human(payload)
|
||||
return
|
||||
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:
|
||||
payload = pyro.apply_workspace_patch(
|
||||
args.workspace_id,
|
||||
patch=args.patch,
|
||||
patch=patch_text,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if bool(args.json):
|
||||
|
|
@ -2653,7 +2710,9 @@ def main() -> None:
|
|||
else:
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
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)
|
||||
else:
|
||||
_print_workspace_shell_summary_human(payload, prefix="workspace-shell-open")
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = (
|
|||
"--secret",
|
||||
"--secret-file",
|
||||
"--json",
|
||||
"--id-only",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--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_FILE_LIST_FLAGS = ("--recursive", "--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_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_SERVICE_LIST_FLAGS = ("--json",)
|
||||
PUBLIC_CLI_WORKSPACE_SERVICE_LOGS_FLAGS = ("--tail-lines", "--all", "--json")
|
||||
|
|
@ -82,6 +83,7 @@ PUBLIC_CLI_WORKSPACE_SHELL_OPEN_FLAGS = (
|
|||
"--rows",
|
||||
"--secret-env",
|
||||
"--json",
|
||||
"--id-only",
|
||||
)
|
||||
PUBLIC_CLI_WORKSPACE_SHELL_READ_FLAGS = (
|
||||
"--cursor",
|
||||
|
|
|
|||
|
|
@ -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 = "3.6.0"
|
||||
DEFAULT_CATALOG_VERSION = "3.7.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -107,6 +109,17 @@ def _log(message: str) -> None:
|
|||
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(
|
||||
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:
|
||||
seed_dir = root / "seed"
|
||||
export_dir = root / "export"
|
||||
patch_path = root / "fix.patch"
|
||||
_write_text(seed_dir / "message.txt", "broken\n")
|
||||
_write_text(
|
||||
seed_dir / "check.sh",
|
||||
|
|
@ -210,6 +224,14 @@ def _scenario_repro_fix_loop(pyro: Pyro, *, root: Path, environment: str) -> Non
|
|||
"}\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
|
||||
try:
|
||||
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
|
||||
failing = pyro.exec_workspace(workspace_id, command="sh check.sh")
|
||||
assert int(failing["exit_code"]) != 0, failing
|
||||
patch = pyro.apply_workspace_patch(
|
||||
patch_output = _run_pyro_cli(
|
||||
"workspace",
|
||||
"patch",
|
||||
"apply",
|
||||
workspace_id,
|
||||
patch=(
|
||||
"--- a/message.txt\n"
|
||||
"+++ b/message.txt\n"
|
||||
"@@ -1 +1 @@\n"
|
||||
"-broken\n"
|
||||
"+fixed\n"
|
||||
),
|
||||
"--patch-file",
|
||||
str(patch_path),
|
||||
cwd=root,
|
||||
)
|
||||
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")
|
||||
assert int(passing["exit_code"]) == 0, passing
|
||||
assert str(passing["stdout"]) == "fixed\n", passing
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
workspace_help = _subparser_choice(parser, "workspace").format_help()
|
||||
assert "stable workspace contract" 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 list" in workspace_help
|
||||
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 snapshot create WORKSPACE_ID 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(
|
||||
_subparser_choice(parser, "workspace"),
|
||||
"create",
|
||||
).format_help()
|
||||
assert "--id-only" in workspace_create_help
|
||||
assert "--name" in workspace_create_help
|
||||
assert "--label" 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"
|
||||
).format_help()
|
||||
assert "--text" in workspace_file_write_help
|
||||
assert "--text-file" in workspace_file_write_help
|
||||
|
||||
workspace_patch_help = _subparser_choice(
|
||||
_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"
|
||||
).format_help()
|
||||
assert "--patch" in workspace_patch_apply_help
|
||||
assert "--patch-file" in workspace_patch_apply_help
|
||||
|
||||
workspace_stop_help = _subparser_choice(
|
||||
_subparser_choice(parser, "workspace"), "stop"
|
||||
|
|
@ -241,7 +245,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None:
|
|||
_subparser_choice(parser, "workspace"),
|
||||
"shell",
|
||||
).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
|
||||
|
||||
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(
|
||||
_subparser_choice(_subparser_choice(parser, "workspace"), "shell"), "open"
|
||||
).format_help()
|
||||
assert "--id-only" in workspace_shell_open_help
|
||||
assert "--cwd" in workspace_shell_open_help
|
||||
assert "--cols" 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\''
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
|
|
@ -601,6 +675,42 @@ def test_cli_workspace_create_prints_json(
|
|||
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(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> 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
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
|
|
|
|||
|
|
@ -478,6 +478,18 @@ def test_run_all_use_case_scenarios_with_fake_pyro(
|
|||
fake_pyro = _FakePyro(tmp_path / "fake-pyro")
|
||||
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
|
||||
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")
|
||||
|
||||
|
|
@ -493,6 +505,18 @@ def test_main_runs_selected_scenario(monkeypatch: pytest.MonkeyPatch, tmp_path:
|
|||
fake_pyro = _FakePyro(tmp_path / "fake-pyro-main")
|
||||
monkeypatch.setattr(smoke_module, "Pyro", lambda: fake_pyro)
|
||||
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(
|
||||
"sys.argv",
|
||||
[
|
||||
|
|
|
|||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -706,7 +706,7 @@ crypto = [
|
|||
|
||||
[[package]]
|
||||
name = "pyro-mcp"
|
||||
version = "3.6.0"
|
||||
version = "3.7.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "mcp" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue