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.
This commit is contained in:
Thales Maciel 2026-03-13 11:43:40 -03:00
parent 407c805ce2
commit 22d284b1f5
13 changed files with 314 additions and 46 deletions

View file

@ -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

View file

@ -23,7 +23,7 @@ It exposes the same runtime in three public forms:
- Stable workspace walkthrough GIF: [docs/assets/workspace-first-run.gif](docs/assets/workspace-first-run.gif)
- 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

View file

@ -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

View file

@ -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

View file

@ -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`.

View file

@ -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

View file

@ -1,6 +1,6 @@
# `3.9.0` Content-Only Reads And Human Output Polish
Status: Planned
Status: Done
## Goal

View file

@ -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" }

View file

@ -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":

View file

@ -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")

View file

@ -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",

View file

@ -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],

2
uv.lock generated
View file

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