From 22d284b1f5a584d95dd6b7d45cd1357d4c1daf2d Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Fri, 13 Mar 2026 11:43:40 -0300 Subject: [PATCH] Add content-only workspace read modes Make the human workspace read commands easier to use in chat transcripts and shell pipelines by adding CLI-only --content-only to workspace file read and workspace disk read. Keep JSON, SDK, and MCP behavior unchanged while fixing the default human rendering so content without a trailing newline is cleanly separated from the summary footer. Update the 3.9.0 docs and roadmap status, and add CLI regression coverage plus a real guest-backed smoke for live and stopped-disk reads. --- CHANGELOG.md | 10 + README.md | 18 +- docs/first-run.md | 10 +- docs/install.md | 14 +- docs/public-contract.md | 4 +- docs/roadmap/llm-chat-ergonomics.md | 11 +- ...tent-only-reads-and-human-output-polish.md | 2 +- pyproject.toml | 2 +- src/pyro_mcp/cli.py | 64 +++++- src/pyro_mcp/contract.py | 4 +- src/pyro_mcp/vm_environments.py | 2 +- tests/test_cli.py | 217 ++++++++++++++++++ uv.lock | 2 +- 13 files changed, 314 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90edb28..c7b46e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable user-visible changes to `pyro-mcp` are documented here. +## 3.9.0 + +- Added `--content-only` to `pyro workspace file read` and + `pyro workspace disk read` so copy-paste flows and chat transcripts can emit + only file content without the human summary footer. +- Polished default human read output so content without a trailing newline is + still separated cleanly from the summary line in merged terminal logs. +- Updated the stable walkthroughs and contract docs to use content-only reads + where plain file content is the intended output. + ## 3.8.0 - Repositioned the MCP/chat-host onramp so `workspace-core` is clearly the diff --git a/README.md b/README.md index e22dc66..ebb6b34 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.8.0: [CHANGELOG.md#380](CHANGELOG.md#380) +- What's new in 3.9.0: [CHANGELOG.md#390](CHANGELOG.md#390) - 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.8.0 +Catalog version: 3.9.0 ... [pull] phase=install environment=debian:12 [pull] phase=ready environment=debian:12 @@ -96,7 +96,7 @@ WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro- 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 file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt pyro workspace snapshot create "$WORKSPACE_ID" checkpoint @@ -133,7 +133,7 @@ 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-file ./app.py`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` +- inspect and edit files without shell quoting with `uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` - diff the live workspace against its create-time baseline with `uvx --from pyro-mcp pyro workspace diff WORKSPACE_ID` - capture a checkpoint with `uvx --from pyro-mcp pyro workspace snapshot create WORKSPACE_ID checkpoint` - reset a broken workspace with `uvx --from pyro-mcp pyro workspace reset WORKSPACE_ID --snapshot checkpoint` @@ -142,7 +142,7 @@ After the quickstart works: - start long-running workspace services with `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` - publish one guest service port to the host with `uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress+published-ports` and `uvx --from pyro-mcp pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` - stop a workspace for offline inspection with `uvx --from pyro-mcp pyro workspace stop WORKSPACE_ID` -- inspect or export one stopped guest rootfs with `uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID`, `uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID note.txt`, and `uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` +- inspect or export one stopped guest rootfs with `uvx --from pyro-mcp pyro workspace disk list WORKSPACE_ID`, `uvx --from pyro-mcp pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `uvx --from pyro-mcp pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` - move to Python or MCP via [docs/integrations.md](docs/integrations.md) ## Chat Host Quickstart @@ -225,7 +225,7 @@ uvx --from pyro-mcp pyro env list Expected output: ```bash -Catalog version: 3.8.0 +Catalog version: 3.9.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. @@ -306,7 +306,7 @@ pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected pyro workspace create debian:12 --network-policy egress+published-ports pyro workspace sync push WORKSPACE_ID ./changes --dest src pyro workspace file list WORKSPACE_ID src --recursive -pyro workspace file read WORKSPACE_ID src/note.txt +pyro workspace file read WORKSPACE_ID src/note.txt --content-only pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch pyro workspace exec WORKSPACE_ID -- cat src/note.txt @@ -330,7 +330,7 @@ pyro workspace service stop WORKSPACE_ID web pyro workspace service stop WORKSPACE_ID worker pyro workspace stop WORKSPACE_ID pyro workspace disk list WORKSPACE_ID -pyro workspace disk read WORKSPACE_ID src/note.txt +pyro workspace disk read WORKSPACE_ID src/note.txt --content-only pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 pyro workspace start WORKSPACE_ID pyro workspace logs WORKSPACE_ID @@ -342,7 +342,7 @@ machine consumption, use `--id-only` for only the identifier or `--json` for the 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.8.0`; if it fails +later host-side changes into a started workspace. Sync is non-atomic in `3.9.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 6d78265..9260bd6 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.8.0 +Catalog version: 3.9.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. @@ -77,7 +77,7 @@ $ export WORKSPACE_ID="$(uvx --from pyro-mcp pyro workspace create debian:12 --s $ 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 file read "$WORKSPACE_ID" note.txt --content-only $ uvx --from pyro-mcp pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch $ uvx --from pyro-mcp pyro workspace exec "$WORKSPACE_ID" -- cat note.txt $ uvx --from pyro-mcp pyro workspace snapshot create "$WORKSPACE_ID" checkpoint @@ -86,7 +86,7 @@ $ uvx --from pyro-mcp pyro workspace reset "$WORKSPACE_ID" --snapshot checkpoint $ uvx --from pyro-mcp pyro workspace export "$WORKSPACE_ID" note.txt --output ./note.txt $ uvx --from pyro-mcp pyro workspace stop "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace disk list "$WORKSPACE_ID" -$ uvx --from pyro-mcp pyro workspace disk read "$WORKSPACE_ID" note.txt +$ uvx --from pyro-mcp pyro workspace disk read "$WORKSPACE_ID" note.txt --content-only $ uvx --from pyro-mcp pyro workspace disk export "$WORKSPACE_ID" --output ./workspace.ext4 $ uvx --from pyro-mcp pyro workspace start "$WORKSPACE_ID" $ uvx --from pyro-mcp pyro workspace delete "$WORKSPACE_ID" @@ -101,7 +101,7 @@ $ uvx --from pyro-mcp pyro workspace list $ uvx --from pyro-mcp pyro workspace update WORKSPACE_ID --label owner=codex $ uvx --from pyro-mcp pyro workspace sync push WORKSPACE_ID ./changes $ uvx --from pyro-mcp pyro workspace file list WORKSPACE_ID src --recursive -$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py +$ uvx --from pyro-mcp pyro workspace file read WORKSPACE_ID src/app.py --content-only $ uvx --from pyro-mcp pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py $ uvx --from pyro-mcp pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch $ uvx --from pyro-mcp pyro workspace create debian:12 --network-policy egress @@ -259,7 +259,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.8.0`; if it fails partway through, prefer `pyro workspace reset` +workspace. Sync is non-atomic in `3.9.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 b853440..22e457c 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.8.0 +Catalog version: 3.9.0 debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. @@ -144,7 +144,7 @@ WORKSPACE_ID="$(pyro workspace create debian:12 --seed-path ./repo --name repro- 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 file read "$WORKSPACE_ID" note.txt --content-only pyro workspace patch apply "$WORKSPACE_ID" --patch-file fix.patch pyro workspace exec "$WORKSPACE_ID" -- cat note.txt pyro workspace snapshot create "$WORKSPACE_ID" checkpoint @@ -221,11 +221,11 @@ 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-file ./app.py`, and `pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` +- model-native file editing: `pyro workspace file read WORKSPACE_ID src/app.py --content-only`, `pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py`, and `pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch` - baseline diff: `pyro workspace diff WORKSPACE_ID` - snapshots and reset: `pyro workspace snapshot create WORKSPACE_ID checkpoint` and `pyro workspace reset WORKSPACE_ID --snapshot checkpoint` - host export: `pyro workspace export WORKSPACE_ID note.txt --output ./note.txt` -- stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` +- stopped-workspace inspection: `pyro workspace stop WORKSPACE_ID`, `pyro workspace disk list WORKSPACE_ID`, `pyro workspace disk read WORKSPACE_ID note.txt --content-only`, and `pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4` - interactive shells: `pyro workspace shell open WORKSPACE_ID --id-only` - long-running services: `pyro workspace service start WORKSPACE_ID app --ready-file .ready -- sh -lc 'touch .ready && while true; do sleep 60; done'` - localhost-published ports: `pyro workspace create debian:12 --network-policy egress+published-ports` and `pyro workspace service start WORKSPACE_ID app --ready-http http://127.0.0.1:8080/ --publish 18080:8080 -- ./start-app` @@ -258,7 +258,7 @@ pyro workspace create debian:12 --seed-path ./repo --secret API_TOKEN=expected pyro workspace create debian:12 --network-policy egress+published-ports pyro workspace sync push WORKSPACE_ID ./changes --dest src pyro workspace file list WORKSPACE_ID src --recursive -pyro workspace file read WORKSPACE_ID src/note.txt +pyro workspace file read WORKSPACE_ID src/note.txt --content-only pyro workspace file write WORKSPACE_ID src/app.py --text-file ./app.py pyro workspace patch apply WORKSPACE_ID --patch-file fix.patch pyro workspace exec WORKSPACE_ID -- cat src/note.txt @@ -282,7 +282,7 @@ pyro workspace service stop WORKSPACE_ID web pyro workspace service stop WORKSPACE_ID worker pyro workspace stop WORKSPACE_ID pyro workspace disk list WORKSPACE_ID -pyro workspace disk read WORKSPACE_ID src/note.txt +pyro workspace disk read WORKSPACE_ID src/note.txt --content-only pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 pyro workspace start WORKSPACE_ID pyro workspace logs WORKSPACE_ID @@ -294,7 +294,7 @@ the identifier programmatically, use `--id-only` for only the identifier or `--j 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.8.0`; if it fails partway through, prefer `pyro workspace reset` to recover +is non-atomic in `3.9.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 2d07c7b..df85826 100644 --- a/docs/public-contract.md +++ b/docs/public-contract.md @@ -90,12 +90,12 @@ Behavioral guarantees: - `pyro workspace stop WORKSPACE_ID` stops one persistent workspace without deleting its `/workspace`, snapshots, or command history. - `pyro workspace start WORKSPACE_ID` restarts one stopped workspace without resetting `/workspace`. - `pyro workspace file list WORKSPACE_ID [PATH] [--recursive]` returns metadata for one live path under `/workspace`. -- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N]` reads one regular text file under `/workspace`. +- `pyro workspace file read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular text file under `/workspace`. - `pyro workspace file write WORKSPACE_ID PATH --text TEXT` and `--text-file PATH` create or replace one regular text file under `/workspace`, creating missing parent directories automatically. - `pyro workspace export WORKSPACE_ID PATH --output HOST_PATH` exports one file or directory from `/workspace` back to the host. - `pyro workspace disk export WORKSPACE_ID --output HOST_PATH` copies the stopped guest-backed workspace rootfs as raw ext4 to the host. - `pyro workspace disk list WORKSPACE_ID [PATH] [--recursive]` inspects a stopped guest-backed workspace rootfs offline without booting the guest. -- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N]` reads one regular file from a stopped guest-backed workspace rootfs offline. +- `pyro workspace disk read WORKSPACE_ID PATH [--max-bytes N] [--content-only]` reads one regular file from a stopped guest-backed workspace rootfs offline. - `pyro workspace disk *` requires `state=stopped` and a guest-backed workspace; it fails on `host_compat`. - `pyro workspace diff WORKSPACE_ID` compares the current `/workspace` tree to the immutable create-time baseline. - `pyro workspace snapshot *` manages explicit named snapshots in addition to the implicit `baseline`. diff --git a/docs/roadmap/llm-chat-ergonomics.md b/docs/roadmap/llm-chat-ergonomics.md index 60dfad5..dcdcebe 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.8.0`: +Current baseline is `3.9.0`: - the stable workspace contract exists across CLI, SDK, and MCP - one-shot `pyro run` still exists as the narrow entrypoint @@ -59,7 +59,7 @@ The remaining UX friction for a technically strong new user is now narrower: 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) - Done 7. [`3.8.0` Chat-Host Onramp And Recommended Defaults](llm-chat-ergonomics/3.8.0-chat-host-onramp-and-recommended-defaults.md) - Done -8. [`3.9.0` Content-Only Reads And Human Output Polish](llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md) - Planned +8. [`3.9.0` Content-Only Reads And Human Output Polish](llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md) - Done Completed so far: @@ -81,11 +81,8 @@ Completed so far: extraction or `$(cat ...)` expansion. - `3.8.0` made `workspace-core` the obvious first MCP/chat-host profile from the first help and docs pass while keeping `workspace-full` as the 3.x compatibility default. - -Planned next: - -- `3.9.0` makes human-mode file reads cleaner in terminals and chat logs, with explicit - content-only reads where summaries would otherwise get in the way. +- `3.9.0` added content-only workspace file and disk reads plus cleaner default human-mode + transcript separation for files that do not end with a trailing newline. ## Expected Outcome diff --git a/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md b/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md index 5b9901e..0fcc400 100644 --- a/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md +++ b/docs/roadmap/llm-chat-ergonomics/3.9.0-content-only-reads-and-human-output-polish.md @@ -1,6 +1,6 @@ # `3.9.0` Content-Only Reads And Human Output Polish -Status: Planned +Status: Done ## Goal diff --git a/pyproject.toml b/pyproject.toml index 9d2e4da..09f08ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyro-mcp" -version = "3.8.0" +version = "3.9.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 85e6d77..fc3b70c 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -45,6 +45,12 @@ def _write_stream(text: str, *, stream: Any) -> None: stream.flush() +def _print_read_content(text: str, *, content_only: bool) -> None: + _write_stream(text, stream=sys.stdout) + if not content_only and text != "" and not text.endswith("\n"): + _write_stream("\n", stream=sys.stdout) + + def _print_run_human(payload: dict[str, Any]) -> None: stdout = str(payload.get("stdout", "")) stderr = str(payload.get("stderr", "")) @@ -339,8 +345,12 @@ def _print_workspace_disk_list_human(payload: dict[str, Any]) -> None: print(line) -def _print_workspace_disk_read_human(payload: dict[str, Any]) -> None: - _write_stream(str(payload.get("content", "")), stream=sys.stdout) +def _print_workspace_disk_read_human( + payload: dict[str, Any], *, content_only: bool = False +) -> None: + _print_read_content(str(payload.get("content", "")), content_only=content_only) + if content_only: + return print( "[workspace-disk-read] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " @@ -397,8 +407,12 @@ def _print_workspace_file_list_human(payload: dict[str, Any]) -> None: print(line) -def _print_workspace_file_read_human(payload: dict[str, Any]) -> None: - _write_stream(str(payload.get("content", "")), stream=sys.stdout) +def _print_workspace_file_read_human( + payload: dict[str, Any], *, content_only: bool = False +) -> None: + _print_read_content(str(payload.get("content", "")), content_only=content_only) + if content_only: + return print( "[workspace-file-read] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " @@ -1223,7 +1237,13 @@ def _build_parser() -> argparse.ArgumentParser: "Read one regular text file under `/workspace`. This is bounded and does not " "follow symlinks." ), - epilog="Example:\n pyro workspace file read WORKSPACE_ID src/app.py", + epilog=dedent( + """ + Examples: + pyro workspace file read WORKSPACE_ID src/app.py + pyro workspace file read WORKSPACE_ID src/app.py --content-only + """ + ), formatter_class=_HelpFormatter, ) workspace_file_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") @@ -1234,7 +1254,13 @@ def _build_parser() -> argparse.ArgumentParser: default=DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES, help="Maximum number of bytes to return in the decoded text response.", ) - workspace_file_read_parser.add_argument( + workspace_file_read_output_group = workspace_file_read_parser.add_mutually_exclusive_group() + workspace_file_read_output_group.add_argument( + "--content-only", + action="store_true", + help="Print only the decoded file content with no human summary footer.", + ) + workspace_file_read_output_group.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", @@ -1535,7 +1561,13 @@ def _build_parser() -> argparse.ArgumentParser: "Read one regular file from a stopped workspace rootfs without booting the guest. " "Relative paths resolve inside `/workspace`; absolute paths inspect any guest path." ), - epilog="Example:\n pyro workspace disk read WORKSPACE_ID note.txt --max-bytes 4096", + epilog=dedent( + """ + Examples: + pyro workspace disk read WORKSPACE_ID note.txt --max-bytes 4096 + pyro workspace disk read WORKSPACE_ID note.txt --content-only + """ + ), formatter_class=_HelpFormatter, ) workspace_disk_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") @@ -1546,7 +1578,13 @@ def _build_parser() -> argparse.ArgumentParser: default=DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES, help="Maximum number of decoded UTF-8 bytes to return.", ) - workspace_disk_read_parser.add_argument( + workspace_disk_read_output_group = workspace_disk_read_parser.add_mutually_exclusive_group() + workspace_disk_read_output_group.add_argument( + "--content-only", + action="store_true", + help="Print only the decoded file content with no human summary footer.", + ) + workspace_disk_read_output_group.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", @@ -2505,7 +2543,10 @@ def main() -> None: if bool(args.json): _print_json(payload) else: - _print_workspace_file_read_human(payload) + _print_workspace_file_read_human( + payload, + content_only=bool(getattr(args, "content_only", False)), + ) return if args.workspace_file_command == "write": text = ( @@ -2698,7 +2739,10 @@ def main() -> None: if bool(args.json): _print_json(payload) else: - _print_workspace_disk_read_human(payload) + _print_workspace_disk_read_human( + payload, + content_only=bool(getattr(args, "content_only", False)), + ) return if args.workspace_command == "shell": if args.workspace_shell_command == "open": diff --git a/src/pyro_mcp/contract.py b/src/pyro_mcp/contract.py index c344cbd..714acf7 100644 --- a/src/pyro_mcp/contract.py +++ b/src/pyro_mcp/contract.py @@ -51,12 +51,12 @@ PUBLIC_CLI_WORKSPACE_CREATE_FLAGS = ( ) PUBLIC_CLI_WORKSPACE_DISK_EXPORT_FLAGS = ("--output", "--json") PUBLIC_CLI_WORKSPACE_DISK_LIST_FLAGS = ("--recursive", "--json") -PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS = ("--max-bytes", "--json") +PUBLIC_CLI_WORKSPACE_DISK_READ_FLAGS = ("--max-bytes", "--content-only", "--json") PUBLIC_CLI_WORKSPACE_EXEC_FLAGS = ("--timeout-seconds", "--secret-env", "--json") 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_READ_FLAGS = ("--max-bytes", "--content-only", "--json") PUBLIC_CLI_WORKSPACE_FILE_WRITE_FLAGS = ("--text", "--text-file", "--json") PUBLIC_CLI_WORKSPACE_LIST_FLAGS = ("--json",) PUBLIC_CLI_WORKSPACE_PATCH_APPLY_FLAGS = ("--patch", "--patch-file", "--json") diff --git a/src/pyro_mcp/vm_environments.py b/src/pyro_mcp/vm_environments.py index c70c41b..365158b 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.8.0" +DEFAULT_CATALOG_VERSION = "3.9.0" OCI_MANIFEST_ACCEPT = ", ".join( ( "application/vnd.oci.image.index.v1+json", diff --git a/tests/test_cli.py b/tests/test_cli.py index d881166..17d84b9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -159,6 +159,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "read" ).format_help() assert "--max-bytes" in workspace_file_read_help + assert "--content-only" in workspace_file_read_help workspace_file_write_help = _subparser_choice( _subparser_choice(_subparser_choice(parser, "workspace"), "file"), "write" @@ -207,6 +208,7 @@ def test_cli_subcommand_help_includes_examples_and_guidance() -> None: _subparser_choice(_subparser_choice(parser, "workspace"), "disk"), "read" ).format_help() assert "--max-bytes" in workspace_disk_read_help + assert "--content-only" in workspace_disk_read_help workspace_diff_help = _subparser_choice( _subparser_choice(parser, "workspace"), "diff" @@ -637,6 +639,32 @@ def test_cli_shortcut_flags_are_mutually_exclusive() -> None: ] ) + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "file", + "read", + "workspace-123", + "note.txt", + "--json", + "--content-only", + ] + ) + + with pytest.raises(SystemExit): + parser.parse_args( + [ + "workspace", + "disk", + "read", + "workspace-123", + "note.txt", + "--json", + "--content-only", + ] + ) + def test_cli_workspace_create_prints_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] @@ -1523,6 +1551,184 @@ def test_cli_workspace_disk_commands_print_human_and_json( assert read_payload["content"] == "hello\n" +def test_cli_workspace_file_read_human_separates_summary_from_content( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert max_bytes == 4096 + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=False, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello\n" + assert "[workspace-file-read] workspace_id=workspace-123" in captured.err + + +def test_cli_workspace_file_read_content_only_suppresses_summary( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_file( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="file", + workspace_file_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=True, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello" + assert captured.err == "" + + +def test_cli_workspace_disk_read_human_separates_summary_from_content( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_disk( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + assert workspace_id == "workspace-123" + assert path == "note.txt" + assert max_bytes == 4096 + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=False, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello\n" + assert "[workspace-disk-read] workspace_id=workspace-123" in captured.err + + +def test_cli_workspace_disk_read_content_only_suppresses_summary( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + class StubPyro: + def read_workspace_disk( + self, + workspace_id: str, + path: str, + *, + max_bytes: int, + ) -> dict[str, Any]: + return { + "workspace_id": workspace_id, + "path": "/workspace/note.txt", + "size_bytes": 5, + "max_bytes": max_bytes, + "content": "hello", + "truncated": False, + } + + class StubParser: + def parse_args(self) -> argparse.Namespace: + return argparse.Namespace( + command="workspace", + workspace_command="disk", + workspace_disk_command="read", + workspace_id="workspace-123", + path="note.txt", + max_bytes=4096, + content_only=True, + json=False, + ) + + monkeypatch.setattr(cli, "Pyro", StubPyro) + monkeypatch.setattr(cli, "_build_parser", lambda: StubParser()) + + cli.main() + captured = capsys.readouterr() + assert captured.out == "hello" + assert captured.err == "" + + def test_cli_workspace_diff_prints_human_output( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -2618,6 +2824,17 @@ def test_chat_host_docs_and_examples_recommend_workspace_core() -> None: assert "Recommended default for most chat hosts: `workspace-core`." in mcp_config +def test_content_only_read_docs_are_aligned() -> None: + readme = Path("README.md").read_text(encoding="utf-8") + install = Path("docs/install.md").read_text(encoding="utf-8") + first_run = Path("docs/first-run.md").read_text(encoding="utf-8") + + assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in readme + assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in install + assert 'workspace file read "$WORKSPACE_ID" note.txt --content-only' in first_run + assert 'workspace disk read "$WORKSPACE_ID" note.txt --content-only' in first_run + + def test_cli_workspace_shell_write_signal_close_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], diff --git a/uv.lock b/uv.lock index 8418840..9e194db 100644 --- a/uv.lock +++ b/uv.lock @@ -706,7 +706,7 @@ crypto = [ [[package]] name = "pyro-mcp" -version = "3.8.0" +version = "3.9.0" source = { editable = "." } dependencies = [ { name = "mcp" },