Add workspace handoff shortcuts and file-backed inputs

Remove the remaining shell glue from the canonical CLI workspace flows so users can hand off IDs and host-authored text files directly.

Add --id-only on workspace create and shell open, plus --text-file and --patch-file for workspace file write and patch apply, while keeping the underlying SDK, MCP, and backend behavior unchanged.

Update the top walkthroughs, contract docs, roadmap status, and use-case smoke runner to use the new shortcuts, and verify the milestone with uv lock, make check, make dist-check, focused CLI tests, and a real guest-backed smoke for create, file write, patch apply, and shell open/read.
This commit is contained in:
Thales Maciel 2026-03-13 11:10:11 -03:00
parent 788fc4fad4
commit 7a0620fc0c
15 changed files with 466 additions and 79 deletions

View file

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

View file

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

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.6.0"
DEFAULT_CATALOG_VERSION = "3.7.0"
OCI_MANIFEST_ACCEPT = ", ".join(
(
"application/vnd.oci.image.index.v1+json",

View file

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