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

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