diff --git a/CHANGELOG.md b/CHANGELOG.md index dbca7a1..02e1f4b 100644 --- a/CHANGELOG.md +++ b/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, diff --git a/README.md b/README.md index 40e310f..d94bbb7 100644 --- a/README.md +++ b/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 diff --git a/docs/first-run.md b/docs/first-run.md index 839740e..e7829c0 100644 --- a/docs/first-run.md +++ b/docs/first-run.md @@ -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 diff --git a/docs/install.md b/docs/install.md index 0327eac..439f56f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -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 diff --git a/docs/public-contract.md b/docs/public-contract.md index 7c76615..cd311c4 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -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`. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 8f7e43f..7c76989 100644 --- a/docs/roadmap/llm-chat-ergonomics.md +++ b/docs/roadmap/llm-chat-ergonomics.md @@ -6,7 +6,7 @@ goal: make the core agent-workspace use cases feel trivial from a chat-driven LLM interface. -Current baseline is `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 diff --git a/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md b/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md index 5e6896f..09812d5 100644 --- a/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md +++ b/docs/roadmap/llm-chat-ergonomics/3.7.0-handoff-shortcuts-and-file-input-sources.md @@ -1,6 +1,6 @@ # `3.7.0` Handoff Shortcuts And File Input Sources -Status: Planned +Status: Done ## Goal diff --git a/pyproject.toml b/pyproject.toml index 235df24..5bf246b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 74dd57c..3490088 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -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") diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index 8a85fa2..c344cbd 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -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", diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index 1616b6b..64278de 100644 --- a/src/pyro_mcp/vm_environments.py +++ b/src/pyro_mcp/vm_environments.py @@ -19,7 +19,7 @@ from typing import Any from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths DEFAULT_ENVIRONMENT_VERSION = "1.0.0" -DEFAULT_CATALOG_VERSION = "3.6.0" +DEFAULT_CATALOG_VERSION = "3.7.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", diff --git a/src/pyro_mcp/workspace_use_case_smokes.py b/src/pyro_mcp/workspace_use_case_smokes.py index 8513dce..5536f37 100644 --- a/src/pyro_mcp/workspace_use_case_smokes.py +++ b/src/pyro_mcp/workspace_use_case_smokes.py @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 0b8ebb5..e14c5ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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], diff --git a/tests/test_workspace_use_case_smokes.py b/tests/test_workspace_use_case_smokes.py index 5cd648c..9e0c178 100644 --- a/tests/test_workspace_use_case_smokes.py +++ b/tests/test_workspace_use_case_smokes.py @@ -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", [ diff --git a/uv.lock b/uv.lock index 4bcd624..a14789a 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "3.6.0" +version = "3.7.0" source = { editable = "." } dependencies = [ { name = "mcp" },