"""Public CLI for pyro-mcp.""" from __future__ import annotations import argparse import json import shlex import sys from textwrap import dedent from typing import Any from pyro_mcp import __version__ from pyro_mcp.api import Pyro from pyro_mcp.demo import run_demo from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION from pyro_mcp.vm_manager import ( DEFAULT_MEM_MIB, DEFAULT_SERVICE_LOG_TAIL_LINES, DEFAULT_SERVICE_READY_INTERVAL_MS, DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, DEFAULT_VCPU_COUNT, DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES, DEFAULT_WORKSPACE_FILE_READ_MAX_BYTES, WORKSPACE_GUEST_PATH, WORKSPACE_SHELL_SIGNAL_NAMES, ) def _print_json(payload: dict[str, Any]) -> None: print(json.dumps(payload, indent=2, sort_keys=True)) def _write_stream(text: str, *, stream: Any) -> None: if text == "": return stream.write(text) stream.flush() def _print_run_human(payload: dict[str, Any]) -> None: stdout = str(payload.get("stdout", "")) stderr = str(payload.get("stderr", "")) _write_stream(stdout, stream=sys.stdout) _write_stream(stderr, stream=sys.stderr) print( "[run] " f"environment={str(payload.get('environment', 'unknown'))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))} " f"exit_code={int(payload.get('exit_code', 1))} " f"duration_ms={int(payload.get('duration_ms', 0))}", file=sys.stderr, flush=True, ) def _print_phase(prefix: str, *, phase: str, **fields: object) -> None: details = " ".join(f"{key}={value}" for key, value in fields.items()) suffix = f" {details}" if details else "" print(f"[{prefix}] phase={phase}{suffix}", file=sys.stderr, flush=True) def _print_env_list_human(payload: dict[str, Any]) -> None: print(f"Catalog version: {payload.get('catalog_version', 'unknown')}") environments = payload.get("environments") if not isinstance(environments, list) or not environments: print("No environments found.") return for entry in environments: if not isinstance(entry, dict): continue status = "installed" if bool(entry.get("installed")) else "not installed" print( f"{str(entry.get('name', 'unknown'))} [{status}] " f"{str(entry.get('description', '')).strip()}".rstrip() ) def _print_env_detail_human(payload: dict[str, Any], *, action: str) -> None: print(f"{action}: {str(payload.get('name', 'unknown'))}") print(f"Version: {str(payload.get('version', 'unknown'))}") print( f"Distribution: {str(payload.get('distribution', 'unknown'))} " f"{str(payload.get('distribution_version', 'unknown'))}" ) print(f"Installed: {'yes' if bool(payload.get('installed')) else 'no'}") print(f"Cache dir: {str(payload.get('cache_dir', 'unknown'))}") packages = payload.get("default_packages") if isinstance(packages, list) and packages: print("Default packages: " + ", ".join(str(item) for item in packages)) description = str(payload.get("description", "")).strip() if description != "": print(f"Description: {description}") if payload.get("installed"): print(f"Install dir: {str(payload.get('install_dir', 'unknown'))}") install_manifest = payload.get("install_manifest") if install_manifest is not None: print(f"Install manifest: {str(install_manifest)}") kernel_image = payload.get("kernel_image") if kernel_image is not None: print(f"Kernel image: {str(kernel_image)}") rootfs_image = payload.get("rootfs_image") if rootfs_image is not None: print(f"Rootfs image: {str(rootfs_image)}") registry = payload.get("oci_registry") repository = payload.get("oci_repository") reference = payload.get("oci_reference") if isinstance(registry, str) and isinstance(repository, str) and isinstance(reference, str): print(f"OCI source: {registry}/{repository}:{reference}") def _print_prune_human(payload: dict[str, Any]) -> None: count = int(payload.get("count", 0)) print(f"Deleted {count} cached environment entr{'y' if count == 1 else 'ies'}.") deleted = payload.get("deleted_environment_dirs") if isinstance(deleted, list): for entry in deleted: print(f"- {entry}") def _print_doctor_human(payload: dict[str, Any]) -> None: issues = payload.get("issues") runtime_ok = bool(payload.get("runtime_ok")) print(f"Platform: {str(payload.get('platform', 'unknown'))}") print(f"Runtime: {'PASS' if runtime_ok else 'FAIL'}") kvm = payload.get("kvm") if isinstance(kvm, dict): print( "KVM: " f"exists={'yes' if bool(kvm.get('exists')) else 'no'} " f"readable={'yes' if bool(kvm.get('readable')) else 'no'} " f"writable={'yes' if bool(kvm.get('writable')) else 'no'}" ) runtime = payload.get("runtime") if isinstance(runtime, dict): print(f"Environment cache: {str(runtime.get('cache_dir', 'unknown'))}") capabilities = runtime.get("capabilities") if isinstance(capabilities, dict): print( "Capabilities: " f"vm_boot={'yes' if bool(capabilities.get('supports_vm_boot')) else 'no'} " f"guest_exec={'yes' if bool(capabilities.get('supports_guest_exec')) else 'no'} " "guest_network=" f"{'yes' if bool(capabilities.get('supports_guest_network')) else 'no'}" ) networking = payload.get("networking") if isinstance(networking, dict): print( "Networking: " f"tun={'yes' if bool(networking.get('tun_available')) else 'no'} " f"ip_forward={'yes' if bool(networking.get('ip_forward_enabled')) else 'no'}" ) if isinstance(issues, list) and issues: print("Issues:") for issue in issues: print(f"- {issue}") def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None: print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}") print(f"Environment: {str(payload.get('environment', 'unknown'))}") print(f"State: {str(payload.get('state', 'unknown'))}") print(f"Workspace: {str(payload.get('workspace_path', '/workspace'))}") print(f"Network policy: {str(payload.get('network_policy', 'off'))}") workspace_seed = payload.get("workspace_seed") if isinstance(workspace_seed, dict): mode = str(workspace_seed.get("mode", "empty")) seed_path = workspace_seed.get("seed_path") if isinstance(seed_path, str) and seed_path != "": print(f"Workspace seed: {mode} from {seed_path}") else: print(f"Workspace seed: {mode}") secrets = payload.get("secrets") if isinstance(secrets, list) and secrets: secret_descriptions = [] for secret in secrets: if not isinstance(secret, dict): continue secret_descriptions.append( f"{str(secret.get('name', 'unknown'))} " f"({str(secret.get('source_kind', 'literal'))})" ) if secret_descriptions: print("Secrets: " + ", ".join(secret_descriptions)) print(f"Execution mode: {str(payload.get('execution_mode', 'pending'))}") print( f"Resources: {int(payload.get('vcpu_count', 0))} vCPU / " f"{int(payload.get('mem_mib', 0))} MiB" ) print(f"Command count: {int(payload.get('command_count', 0))}") print(f"Reset count: {int(payload.get('reset_count', 0))}") last_reset_at = payload.get("last_reset_at") if last_reset_at is not None: print(f"Last reset at: {last_reset_at}") print( "Services: " f"{int(payload.get('running_service_count', 0))}/" f"{int(payload.get('service_count', 0))} running" ) last_command = payload.get("last_command") if isinstance(last_command, dict): print( "Last command: " f"{str(last_command.get('command', 'unknown'))} " f"(exit_code={int(last_command.get('exit_code', -1))})" ) def _print_workspace_exec_human(payload: dict[str, Any]) -> None: stdout = str(payload.get("stdout", "")) stderr = str(payload.get("stderr", "")) _write_stream(stdout, stream=sys.stdout) _write_stream(stderr, stream=sys.stderr) print( "[workspace-exec] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"sequence={int(payload.get('sequence', 0))} " f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))} " f"exit_code={int(payload.get('exit_code', 1))} " f"duration_ms={int(payload.get('duration_ms', 0))}", file=sys.stderr, flush=True, ) def _print_workspace_sync_human(payload: dict[str, Any]) -> None: workspace_sync = payload.get("workspace_sync") if not isinstance(workspace_sync, dict): print(f"Synced workspace: {str(payload.get('workspace_id', 'unknown'))}") return print( "[workspace-sync] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"mode={str(workspace_sync.get('mode', 'unknown'))} " f"source={str(workspace_sync.get('source_path', 'unknown'))} " f"destination={str(workspace_sync.get('destination', WORKSPACE_GUEST_PATH))} " f"entry_count={int(workspace_sync.get('entry_count', 0))} " f"bytes_written={int(workspace_sync.get('bytes_written', 0))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" ) def _print_workspace_export_human(payload: dict[str, Any]) -> None: print( "[workspace-export] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"workspace_path={str(payload.get('workspace_path', WORKSPACE_GUEST_PATH))} " f"output_path={str(payload.get('output_path', 'unknown'))} " f"artifact_type={str(payload.get('artifact_type', 'unknown'))} " f"entry_count={int(payload.get('entry_count', 0))} " f"bytes_written={int(payload.get('bytes_written', 0))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" ) def _print_workspace_disk_export_human(payload: dict[str, Any]) -> None: print( "[workspace-disk-export] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"output_path={str(payload.get('output_path', 'unknown'))} " f"disk_format={str(payload.get('disk_format', 'unknown'))} " f"bytes_written={int(payload.get('bytes_written', 0))}" ) def _print_workspace_disk_list_human(payload: dict[str, Any]) -> None: print( f"Workspace disk path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} " f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})" ) entries = payload.get("entries") if not isinstance(entries, list) or not entries: print("No workspace disk entries found.") return for entry in entries: if not isinstance(entry, dict): continue line = ( f"{str(entry.get('path', 'unknown'))} " f"[{str(entry.get('artifact_type', 'unknown'))}] " f"size={int(entry.get('size_bytes', 0))}" ) link_target = entry.get("link_target") if isinstance(link_target, str) and link_target != "": line += f" -> {link_target}" print(line) def _print_workspace_disk_read_human(payload: dict[str, Any]) -> None: _write_stream(str(payload.get("content", "")), stream=sys.stdout) print( "[workspace-disk-read] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"path={str(payload.get('path', 'unknown'))} " f"size_bytes={int(payload.get('size_bytes', 0))} " f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}", file=sys.stderr, flush=True, ) def _print_workspace_diff_human(payload: dict[str, Any]) -> None: if not bool(payload.get("changed")): print("No workspace changes.") return summary = payload.get("summary") if isinstance(summary, dict): print( "[workspace-diff] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"total={int(summary.get('total', 0))} " f"added={int(summary.get('added', 0))} " f"modified={int(summary.get('modified', 0))} " f"deleted={int(summary.get('deleted', 0))} " f"type_changed={int(summary.get('type_changed', 0))} " f"text_patched={int(summary.get('text_patched', 0))} " f"non_text={int(summary.get('non_text', 0))}" ) patch = str(payload.get("patch", "")) if patch != "": print(patch, end="" if patch.endswith("\n") else "\n") def _print_workspace_file_list_human(payload: dict[str, Any]) -> None: print( f"Workspace path: {str(payload.get('path', WORKSPACE_GUEST_PATH))} " f"(recursive={'yes' if bool(payload.get('recursive')) else 'no'})" ) entries = payload.get("entries") if not isinstance(entries, list) or not entries: print("No workspace entries found.") return for entry in entries: if not isinstance(entry, dict): continue line = ( f"{str(entry.get('path', 'unknown'))} " f"[{str(entry.get('artifact_type', 'unknown'))}] " f"size={int(entry.get('size_bytes', 0))}" ) link_target = entry.get("link_target") if isinstance(link_target, str) and link_target != "": line += f" -> {link_target}" print(line) def _print_workspace_file_read_human(payload: dict[str, Any]) -> None: _write_stream(str(payload.get("content", "")), stream=sys.stdout) print( "[workspace-file-read] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"path={str(payload.get('path', 'unknown'))} " f"size_bytes={int(payload.get('size_bytes', 0))} " f"truncated={'yes' if bool(payload.get('truncated', False)) else 'no'}", file=sys.stderr, flush=True, ) def _print_workspace_file_write_human(payload: dict[str, Any]) -> None: print( "[workspace-file-write] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"path={str(payload.get('path', 'unknown'))} " f"bytes_written={int(payload.get('bytes_written', 0))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" ) def _print_workspace_patch_human(payload: dict[str, Any]) -> None: summary = payload.get("summary") if isinstance(summary, dict): print( "[workspace-patch] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"total={int(summary.get('total', 0))} " f"added={int(summary.get('added', 0))} " f"modified={int(summary.get('modified', 0))} " f"deleted={int(summary.get('deleted', 0))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" ) return print( "[workspace-patch] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" ) def _print_workspace_logs_human(payload: dict[str, Any]) -> None: entries = payload.get("entries") if not isinstance(entries, list) or not entries: print("No workspace logs found.") return for entry in entries: if not isinstance(entry, dict): continue print( f"#{int(entry.get('sequence', 0))} " f"exit_code={int(entry.get('exit_code', -1))} " f"duration_ms={int(entry.get('duration_ms', 0))} " f"cwd={str(entry.get('cwd', WORKSPACE_GUEST_PATH))}" ) print(f"$ {str(entry.get('command', ''))}") stdout = str(entry.get("stdout", "")) stderr = str(entry.get("stderr", "")) if stdout != "": print(stdout, end="" if stdout.endswith("\n") else "\n") if stderr != "": print(stderr, end="" if stderr.endswith("\n") else "\n", file=sys.stderr) def _print_workspace_snapshot_human(payload: dict[str, Any], *, prefix: str) -> None: snapshot = payload.get("snapshot") if not isinstance(snapshot, dict): print(f"[{prefix}] workspace_id={str(payload.get('workspace_id', 'unknown'))}") return print( f"[{prefix}] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"snapshot_name={str(snapshot.get('snapshot_name', 'unknown'))} " f"kind={str(snapshot.get('kind', 'unknown'))} " f"entry_count={int(snapshot.get('entry_count', 0))} " f"bytes_written={int(snapshot.get('bytes_written', 0))}" ) def _print_workspace_snapshot_list_human(payload: dict[str, Any]) -> None: snapshots = payload.get("snapshots") if not isinstance(snapshots, list) or not snapshots: print("No workspace snapshots found.") return for snapshot in snapshots: if not isinstance(snapshot, dict): continue print( f"{str(snapshot.get('snapshot_name', 'unknown'))} " f"[{str(snapshot.get('kind', 'unknown'))}] " f"entry_count={int(snapshot.get('entry_count', 0))} " f"bytes_written={int(snapshot.get('bytes_written', 0))} " f"deletable={'yes' if bool(snapshot.get('deletable', False)) else 'no'}" ) def _print_workspace_reset_human(payload: dict[str, Any]) -> None: _print_workspace_summary_human(payload, action="Reset workspace") workspace_reset = payload.get("workspace_reset") if isinstance(workspace_reset, dict): print( "Reset source: " f"{str(workspace_reset.get('snapshot_name', 'unknown'))} " f"({str(workspace_reset.get('kind', 'unknown'))})" ) print( "Reset restore: " f"destination={str(workspace_reset.get('destination', WORKSPACE_GUEST_PATH))} " f"entry_count={int(workspace_reset.get('entry_count', 0))} " f"bytes_written={int(workspace_reset.get('bytes_written', 0))}" ) def _print_workspace_shell_summary_human(payload: dict[str, Any], *, prefix: str) -> None: print( f"[{prefix}] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"shell_id={str(payload.get('shell_id', 'unknown'))} " f"state={str(payload.get('state', 'unknown'))} " f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " f"cols={int(payload.get('cols', 0))} " f"rows={int(payload.get('rows', 0))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}", file=sys.stderr, flush=True, ) def _print_workspace_shell_read_human(payload: dict[str, Any]) -> None: _write_stream(str(payload.get("output", "")), stream=sys.stdout) print( "[workspace-shell-read] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"shell_id={str(payload.get('shell_id', 'unknown'))} " f"state={str(payload.get('state', 'unknown'))} " f"cursor={int(payload.get('cursor', 0))} " f"next_cursor={int(payload.get('next_cursor', 0))} " f"truncated={bool(payload.get('truncated', False))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}", file=sys.stderr, flush=True, ) def _print_workspace_service_summary_human(payload: dict[str, Any], *, prefix: str) -> None: published_ports = payload.get("published_ports") published_text = "" if isinstance(published_ports, list) and published_ports: parts = [] for item in published_ports: if not isinstance(item, dict): continue parts.append( f"{str(item.get('host', '127.0.0.1'))}:{int(item.get('host_port', 0))}" f"->{int(item.get('guest_port', 0))}/{str(item.get('protocol', 'tcp'))}" ) if parts: published_text = " published_ports=" + ",".join(parts) print( f"[{prefix}] " f"workspace_id={str(payload.get('workspace_id', 'unknown'))} " f"service_name={str(payload.get('service_name', 'unknown'))} " f"state={str(payload.get('state', 'unknown'))} " f"cwd={str(payload.get('cwd', WORKSPACE_GUEST_PATH))} " f"execution_mode={str(payload.get('execution_mode', 'unknown'))}" f"{published_text}", file=sys.stderr, flush=True, ) def _print_workspace_service_list_human(payload: dict[str, Any]) -> None: services = payload.get("services") if not isinstance(services, list) or not services: print("No workspace services found.") return for service in services: if not isinstance(service, dict): continue print( f"{str(service.get('service_name', 'unknown'))} " f"[{str(service.get('state', 'unknown'))}] " f"cwd={str(service.get('cwd', WORKSPACE_GUEST_PATH))}" + ( " published=" + ",".join( f"{str(item.get('host', '127.0.0.1'))}:{int(item.get('host_port', 0))}" f"->{int(item.get('guest_port', 0))}/{str(item.get('protocol', 'tcp'))}" for item in service.get("published_ports", []) if isinstance(item, dict) ) if isinstance(service.get("published_ports"), list) and service.get("published_ports") else "" ) ) def _print_workspace_service_logs_human(payload: dict[str, Any]) -> None: stdout = str(payload.get("stdout", "")) stderr = str(payload.get("stderr", "")) _write_stream(stdout, stream=sys.stdout) _write_stream(stderr, stream=sys.stderr) _print_workspace_service_summary_human(payload, prefix="workspace-service-logs") class _HelpFormatter( argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter, ): """Help formatter with examples and default values.""" def _get_help_string(self, action: argparse.Action) -> str: if action.default is None and action.help is not None: return action.help help_string = super()._get_help_string(action) if help_string is None: return "" return help_string def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Run stable one-shot and persistent workspace workflows on supported " "Linux x86_64 KVM hosts." ), epilog=dedent( """ Suggested first run: pyro doctor pyro env list pyro env pull debian:12 pyro run debian:12 -- git --version Continue into the stable workspace path after that: pyro workspace create debian:12 --seed-path ./repo 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 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 Use `pyro mcp serve` only after the CLI validation path works. """ ), formatter_class=_HelpFormatter, ) parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") env_parser = subparsers.add_parser( "env", help="Inspect and manage curated environments.", description="Inspect, install, and prune curated Linux environments.", epilog=dedent( """ Examples: pyro env list pyro env pull debian:12 pyro env inspect debian:12 """ ), formatter_class=_HelpFormatter, ) env_subparsers = env_parser.add_subparsers(dest="env_command", required=True, metavar="ENV") list_parser = env_subparsers.add_parser( "list", help="List official environments.", description="List the shipped environment catalog and show local install status.", epilog="Example:\n pyro env list", formatter_class=_HelpFormatter, ) list_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) pull_parser = env_subparsers.add_parser( "pull", help="Install an environment into the local cache.", description=( "Download and install one official environment into the local cache from " "the configured OCI registry." ), epilog=dedent( """ Example: pyro env pull debian:12 The first pull downloads from public Docker Hub, requires outbound HTTPS, and needs local cache space for the guest image. """ ), formatter_class=_HelpFormatter, ) pull_parser.add_argument( "environment", metavar="ENVIRONMENT", help="Environment name from `pyro env list`, for example `debian:12`.", ) pull_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) inspect_parser = env_subparsers.add_parser( "inspect", help="Inspect one environment.", description="Show catalog and local cache details for one environment.", epilog="Example:\n pyro env inspect debian:12", formatter_class=_HelpFormatter, ) inspect_parser.add_argument( "environment", metavar="ENVIRONMENT", help="Environment name from `pyro env list`, for example `debian:12`.", ) inspect_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) prune_parser = env_subparsers.add_parser( "prune", help="Delete stale cached environments.", description="Remove cached environment installs that are no longer referenced.", epilog="Example:\n pyro env prune", formatter_class=_HelpFormatter, ) prune_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) mcp_parser = subparsers.add_parser( "mcp", help="Run the MCP server.", description=( "Run the MCP server after you have already validated the host and " "guest execution with `pyro doctor` and `pyro run`." ), epilog="Example:\n pyro mcp serve", formatter_class=_HelpFormatter, ) mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True, metavar="MCP") mcp_subparsers.add_parser( "serve", help="Run the MCP server over stdio.", description="Expose pyro tools over stdio for an MCP client.", epilog=dedent( """ Example: pyro mcp serve Use this from an MCP client config after the CLI evaluation path works. """ ), formatter_class=_HelpFormatter, ) run_parser = subparsers.add_parser( "run", help="Run one command inside an ephemeral VM.", description="Run one non-interactive command in a temporary Firecracker microVM.", epilog=dedent( """ Examples: pyro run debian:12 -- git --version pyro run debian:12 --network -- python3 -c "import urllib.request as u; print(u.urlopen('https://example.com').status)" The guest command output and the [run] summary are written to different streams, so they may appear in either order. Use --json for a deterministic structured result. """ ), formatter_class=_HelpFormatter, ) run_parser.add_argument( "environment", metavar="ENVIRONMENT", help="Curated environment to boot, for example `debian:12`.", ) run_parser.add_argument( "--vcpu-count", type=int, default=DEFAULT_VCPU_COUNT, help="Number of virtual CPUs to allocate to the guest.", ) run_parser.add_argument( "--mem-mib", type=int, default=DEFAULT_MEM_MIB, help="Guest memory allocation in MiB.", ) run_parser.add_argument( "--timeout-seconds", type=int, default=30, help="Maximum time allowed for the guest command.", ) run_parser.add_argument( "--ttl-seconds", type=int, default=600, help="Time-to-live for temporary VM artifacts before cleanup.", ) run_parser.add_argument( "--network", action="store_true", help="Enable outbound guest networking. Requires TAP/NAT privileges on the host.", ) run_parser.add_argument( "--allow-host-compat", action="store_true", help=( "Opt into host-side compatibility execution if guest boot or guest exec " "is unavailable." ), ) run_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) run_parser.add_argument( "command_args", nargs="*", metavar="ARG", help=( "Command and arguments to run inside the guest. Prefix them with `--`, " "for example `pyro run debian:12 -- git --version`." ), ) workspace_parser = subparsers.add_parser( "workspace", help="Manage persistent workspaces.", description=( "Use the stable workspace contract when you need one sandbox to stay alive " "across repeated exec, shell, service, diff, export, snapshot, and reset calls." ), epilog=dedent( """ Examples: pyro workspace create debian:12 --seed-path ./repo 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 exec WORKSPACE_ID -- sh -lc 'printf "hello\\n" > note.txt' pyro workspace stop WORKSPACE_ID pyro workspace disk list WORKSPACE_ID pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 pyro workspace start WORKSPACE_ID pyro workspace snapshot create WORKSPACE_ID checkpoint 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 service start WORKSPACE_ID app --ready-file .ready -- \ sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace logs WORKSPACE_ID `pyro run` remains the fastest one-shot proof. `pyro workspace ...` is the stable path when an agent needs to inhabit one sandbox over time. """ ), formatter_class=_HelpFormatter, ) workspace_subparsers = workspace_parser.add_subparsers( dest="workspace_command", required=True, metavar="WORKSPACE", ) workspace_create_parser = workspace_subparsers.add_parser( "create", help="Create and start a persistent workspace.", description=( "Create and start a stable persistent workspace that stays alive across repeated " "exec, shell, service, diff, export, snapshot, and reset calls." ), epilog=dedent( """ Examples: pyro workspace create debian:12 pyro workspace create debian:12 --seed-path ./repo pyro workspace create debian:12 --network-policy egress pyro workspace create debian:12 --secret API_TOKEN=expected pyro workspace sync push WORKSPACE_ID ./changes pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace reset WORKSPACE_ID --snapshot checkpoint pyro workspace diff WORKSPACE_ID pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ sh -lc 'touch .ready && while true; do sleep 60; done' """ ), formatter_class=_HelpFormatter, ) workspace_create_parser.add_argument( "environment", metavar="ENVIRONMENT", help="Curated environment to boot, for example `debian:12`.", ) workspace_create_parser.add_argument( "--vcpu-count", type=int, default=DEFAULT_VCPU_COUNT, help="Number of virtual CPUs to allocate to the guest.", ) workspace_create_parser.add_argument( "--mem-mib", type=int, default=DEFAULT_MEM_MIB, help="Guest memory allocation in MiB.", ) workspace_create_parser.add_argument( "--ttl-seconds", type=int, default=600, help="Time-to-live for the workspace before automatic cleanup.", ) workspace_create_parser.add_argument( "--network-policy", choices=("off", "egress", "egress+published-ports"), default="off", help="Workspace network policy.", ) workspace_create_parser.add_argument( "--allow-host-compat", action="store_true", help=( "Opt into host-side compatibility execution if guest boot or guest exec " "is unavailable." ), ) workspace_create_parser.add_argument( "--seed-path", help=( "Optional host directory or .tar/.tar.gz/.tgz archive to seed into `/workspace` " "before the workspace is returned." ), ) workspace_create_parser.add_argument( "--secret", action="append", default=[], metavar="NAME=VALUE", help="Persist one literal UTF-8 secret for this workspace.", ) workspace_create_parser.add_argument( "--secret-file", action="append", default=[], metavar="NAME=PATH", help="Persist one UTF-8 secret copied from a host file at create time.", ) workspace_create_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_exec_parser = workspace_subparsers.add_parser( "exec", help="Run one command inside an existing workspace.", description=( "Run one non-interactive command in the persistent `/workspace` " "for a workspace." ), epilog=dedent( """ Examples: pyro workspace exec WORKSPACE_ID -- cat note.txt pyro workspace exec WORKSPACE_ID --secret-env API_TOKEN -- \ sh -lc 'test \"$API_TOKEN\" = \"expected\"' """ ), formatter_class=_HelpFormatter, ) workspace_exec_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_exec_parser.add_argument( "--timeout-seconds", type=int, default=30, help="Maximum time allowed for the workspace command.", ) workspace_exec_parser.add_argument( "--secret-env", action="append", default=[], metavar="SECRET[=ENV_VAR]", help="Expose one persisted workspace secret as an environment variable for this exec.", ) workspace_exec_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_exec_parser.add_argument( "command_args", nargs="*", metavar="ARG", help=( "Command and arguments to run inside the workspace. Prefix them with `--`, " "for example `pyro workspace exec WORKSPACE_ID -- cat note.txt`." ), ) workspace_sync_parser = workspace_subparsers.add_parser( "sync", help="Push host content into a started workspace.", description=( "Push host directory or archive content into `/workspace` for an existing " "started workspace." ), epilog=dedent( """ Examples: pyro workspace sync push WORKSPACE_ID ./repo pyro workspace sync push WORKSPACE_ID ./patches --dest src Sync is non-atomic. If a sync fails partway through, prefer reset over repair with `pyro workspace reset WORKSPACE_ID`. """ ), formatter_class=_HelpFormatter, ) workspace_sync_subparsers = workspace_sync_parser.add_subparsers( dest="workspace_sync_command", required=True, metavar="SYNC", ) workspace_sync_push_parser = workspace_sync_subparsers.add_parser( "push", help="Push one host directory or archive into a started workspace.", description="Import host content into `/workspace` or a subdirectory of it.", epilog="Example:\n pyro workspace sync push WORKSPACE_ID ./repo --dest src", formatter_class=_HelpFormatter, ) workspace_sync_push_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_sync_push_parser.add_argument( "source_path", metavar="SOURCE_PATH", help="Host directory or .tar/.tar.gz/.tgz archive to push into the workspace.", ) workspace_sync_push_parser.add_argument( "--dest", default=WORKSPACE_GUEST_PATH, help="Workspace destination path. Relative values resolve inside `/workspace`.", ) workspace_sync_push_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_export_parser = workspace_subparsers.add_parser( "export", help="Export one workspace path to the host.", description="Export one file or directory from `/workspace` to an explicit host path.", epilog="Example:\n pyro workspace export WORKSPACE_ID src/note.txt --output ./note.txt", formatter_class=_HelpFormatter, ) workspace_export_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_export_parser.add_argument( "path", metavar="PATH", help="Workspace path to export. Relative values resolve inside `/workspace`.", ) workspace_export_parser.add_argument( "--output", required=True, help="Exact host path to create for the exported file or directory.", ) workspace_export_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_diff_parser = workspace_subparsers.add_parser( "diff", help="Diff `/workspace` against the create-time baseline.", description="Compare the current `/workspace` tree to the immutable workspace baseline.", epilog=dedent( """ Example: pyro workspace diff WORKSPACE_ID Use `workspace export` to copy a changed file or directory back to the host. """ ), formatter_class=_HelpFormatter, ) workspace_diff_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_diff_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_file_parser = workspace_subparsers.add_parser( "file", help="List, read, and write workspace files without shell quoting.", description=( "Use workspace file operations for model-native tree inspection and text edits " "inside one started workspace." ), epilog=dedent( """ 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")' """ ), formatter_class=_HelpFormatter, ) workspace_file_subparsers = workspace_file_parser.add_subparsers( dest="workspace_file_command", required=True, metavar="FILE", ) workspace_file_list_parser = workspace_file_subparsers.add_parser( "list", help="List metadata for one live workspace path.", description="List files, directories, and symlinks under one started workspace path.", epilog="Example:\n pyro workspace file list WORKSPACE_ID src --recursive", formatter_class=_HelpFormatter, ) workspace_file_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_file_list_parser.add_argument( "path", nargs="?", default=WORKSPACE_GUEST_PATH, metavar="PATH", help="Workspace path to inspect. Relative values resolve inside `/workspace`.", ) workspace_file_list_parser.add_argument( "--recursive", action="store_true", help="Walk directories recursively.", ) workspace_file_list_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_file_read_parser = workspace_file_subparsers.add_parser( "read", help="Read one regular text file from a started workspace.", description=( "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", formatter_class=_HelpFormatter, ) workspace_file_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_file_read_parser.add_argument("path", metavar="PATH") workspace_file_read_parser.add_argument( "--max-bytes", type=int, 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( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_file_write_parser = workspace_file_subparsers.add_parser( "write", help="Create or replace one regular text file in a started workspace.", description=( "Write one UTF-8 text file under `/workspace`. Missing parent directories are " "created automatically." ), epilog=( "Example:\n" " pyro workspace file write WORKSPACE_ID src/app.py --text 'print(\"hi\")'" ), 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( "--text", required=True, help="UTF-8 text content to write into the target file.", ) workspace_file_write_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_patch_parser = workspace_subparsers.add_parser( "patch", help="Apply unified text patches inside a started workspace.", description=( "Apply add/modify/delete unified text patches under `/workspace` without shell " "editing tricks." ), epilog=dedent( """ Example: pyro workspace patch apply WORKSPACE_ID --patch "$(cat fix.patch)" Patch application is preflighted but not fully transactional. If an apply fails partway through, prefer `pyro workspace reset WORKSPACE_ID`. """ ), formatter_class=_HelpFormatter, ) workspace_patch_subparsers = workspace_patch_parser.add_subparsers( dest="workspace_patch_command", required=True, metavar="PATCH", ) workspace_patch_apply_parser = workspace_patch_subparsers.add_parser( "apply", help="Apply one unified text patch to a started workspace.", description=( "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)\"", formatter_class=_HelpFormatter, ) workspace_patch_apply_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_patch_apply_parser.add_argument( "--patch", required=True, help="Unified text patch to apply under `/workspace`.", ) workspace_patch_apply_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_snapshot_parser = workspace_subparsers.add_parser( "snapshot", help="Create, list, and delete workspace snapshots.", description=( "Manage explicit named snapshots in addition to the implicit create-time baseline." ), epilog=dedent( """ Examples: pyro workspace snapshot create WORKSPACE_ID checkpoint pyro workspace snapshot list WORKSPACE_ID pyro workspace snapshot delete WORKSPACE_ID checkpoint Use `workspace reset` to restore `/workspace` from `baseline` or one named snapshot. """ ), formatter_class=_HelpFormatter, ) workspace_snapshot_subparsers = workspace_snapshot_parser.add_subparsers( dest="workspace_snapshot_command", required=True, metavar="SNAPSHOT", ) workspace_snapshot_create_parser = workspace_snapshot_subparsers.add_parser( "create", help="Create one named snapshot from the current workspace.", description="Capture the current `/workspace` tree as one named snapshot.", epilog="Example:\n pyro workspace snapshot create WORKSPACE_ID checkpoint", formatter_class=_HelpFormatter, ) workspace_snapshot_create_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_snapshot_create_parser.add_argument("snapshot_name", metavar="SNAPSHOT_NAME") workspace_snapshot_create_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_snapshot_list_parser = workspace_snapshot_subparsers.add_parser( "list", help="List the baseline plus named snapshots.", description="List the implicit baseline snapshot plus any named snapshots for a workspace.", epilog="Example:\n pyro workspace snapshot list WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_snapshot_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_snapshot_list_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_snapshot_delete_parser = workspace_snapshot_subparsers.add_parser( "delete", help="Delete one named snapshot.", description="Delete one named snapshot while leaving the implicit baseline intact.", epilog="Example:\n pyro workspace snapshot delete WORKSPACE_ID checkpoint", formatter_class=_HelpFormatter, ) workspace_snapshot_delete_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_snapshot_delete_parser.add_argument("snapshot_name", metavar="SNAPSHOT_NAME") workspace_snapshot_delete_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_reset_parser = workspace_subparsers.add_parser( "reset", help="Recreate a workspace from baseline or one named snapshot.", description=( "Recreate the full sandbox and restore `/workspace` from the baseline " "or one named snapshot." ), epilog=dedent( """ Examples: pyro workspace reset WORKSPACE_ID pyro workspace reset WORKSPACE_ID --snapshot checkpoint Prefer reset over repair: reset clears command history, shells, and services so the workspace comes back clean from `baseline` or one named snapshot. """ ), formatter_class=_HelpFormatter, ) workspace_reset_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_reset_parser.add_argument( "--snapshot", default="baseline", help="Snapshot name to restore. Defaults to the implicit `baseline` snapshot.", ) workspace_reset_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_stop_parser = workspace_subparsers.add_parser( "stop", help="Stop one workspace without resetting it.", description=( "Stop the backing sandbox, close shells, stop services, and preserve the " "workspace filesystem, history, and snapshots." ), epilog="Example:\n pyro workspace stop WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_stop_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_stop_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_start_parser = workspace_subparsers.add_parser( "start", help="Start one stopped workspace without resetting it.", description=( "Start a previously stopped workspace from its preserved rootfs and " "workspace state." ), epilog="Example:\n pyro workspace start WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_start_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_start_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_disk_parser = workspace_subparsers.add_parser( "disk", help="Inspect or export a stopped workspace disk.", description=( "Use secondary stopped-workspace disk tools for raw ext4 export and offline " "inspection without booting the guest." ), epilog=dedent( """ Examples: pyro workspace stop WORKSPACE_ID pyro workspace disk list WORKSPACE_ID pyro workspace disk read WORKSPACE_ID note.txt pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4 Disk tools are secondary to `workspace export` and require a stopped, guest-backed workspace. """ ), formatter_class=_HelpFormatter, ) workspace_disk_subparsers = workspace_disk_parser.add_subparsers( dest="workspace_disk_command", required=True, metavar="DISK", ) workspace_disk_export_parser = workspace_disk_subparsers.add_parser( "export", help="Export the raw stopped workspace rootfs image.", description="Copy the raw stopped workspace rootfs ext4 image to an explicit host path.", epilog="Example:\n pyro workspace disk export WORKSPACE_ID --output ./workspace.ext4", formatter_class=_HelpFormatter, ) workspace_disk_export_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_disk_export_parser.add_argument( "--output", required=True, help="Exact host path to create for the exported raw ext4 image.", ) workspace_disk_export_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_disk_list_parser = workspace_disk_subparsers.add_parser( "list", help="List files from a stopped workspace rootfs path.", description=( "Inspect one stopped workspace rootfs path without booting the guest. Relative " "paths resolve inside `/workspace`; absolute paths inspect any guest path." ), epilog="Example:\n pyro workspace disk list WORKSPACE_ID src --recursive", formatter_class=_HelpFormatter, ) workspace_disk_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_disk_list_parser.add_argument( "path", nargs="?", default=WORKSPACE_GUEST_PATH, metavar="PATH", help="Guest path to inspect. Defaults to `/workspace`.", ) workspace_disk_list_parser.add_argument( "--recursive", action="store_true", help="Recurse into nested directories.", ) workspace_disk_list_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_disk_read_parser = workspace_disk_subparsers.add_parser( "read", help="Read one regular file from a stopped workspace rootfs.", description=( "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", formatter_class=_HelpFormatter, ) workspace_disk_read_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_disk_read_parser.add_argument("path", metavar="PATH") workspace_disk_read_parser.add_argument( "--max-bytes", type=int, default=DEFAULT_WORKSPACE_DISK_READ_MAX_BYTES, help="Maximum number of decoded UTF-8 bytes to return.", ) workspace_disk_read_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_shell_parser = workspace_subparsers.add_parser( "shell", help="Open and manage persistent interactive shells.", description=( "Open one or more persistent interactive PTY shell sessions inside a started " "workspace." ), epilog=dedent( """ Examples: pyro workspace shell open WORKSPACE_ID pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd' pyro workspace shell read WORKSPACE_ID SHELL_ID pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT pyro workspace shell close WORKSPACE_ID SHELL_ID Use `workspace exec` for one-shot commands. Use `workspace shell` when you need an interactive process that keeps its state between calls. """ ), formatter_class=_HelpFormatter, ) workspace_shell_subparsers = workspace_shell_parser.add_subparsers( dest="workspace_shell_command", required=True, metavar="SHELL", ) workspace_shell_open_parser = workspace_shell_subparsers.add_parser( "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", formatter_class=_HelpFormatter, ) workspace_shell_open_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_shell_open_parser.add_argument( "--cwd", default=WORKSPACE_GUEST_PATH, help="Shell working directory. Relative values resolve inside `/workspace`.", ) workspace_shell_open_parser.add_argument( "--cols", type=int, default=120, help="Shell terminal width in columns.", ) workspace_shell_open_parser.add_argument( "--rows", type=int, default=30, help="Shell terminal height in rows.", ) workspace_shell_open_parser.add_argument( "--secret-env", action="append", default=[], metavar="SECRET[=ENV_VAR]", help="Expose one persisted workspace secret as an environment variable in the shell.", ) workspace_shell_open_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_shell_read_parser = workspace_shell_subparsers.add_parser( "read", help="Read merged PTY output from a shell.", description="Read merged text output from a persistent workspace shell.", epilog=dedent( """ Example: pyro workspace shell read WORKSPACE_ID SHELL_ID --cursor 0 Shell output is written to stdout. The read summary is written to stderr. Use --json for a deterministic structured response. """ ), formatter_class=_HelpFormatter, ) workspace_shell_read_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_shell_read_parser.add_argument( "shell_id", metavar="SHELL_ID", help="Persistent shell identifier returned by `workspace shell open`.", ) workspace_shell_read_parser.add_argument( "--cursor", type=int, default=0, help="Character offset into the merged shell output buffer.", ) workspace_shell_read_parser.add_argument( "--max-chars", type=int, default=65536, help="Maximum number of characters to return from the current cursor position.", ) workspace_shell_read_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_shell_write_parser = workspace_shell_subparsers.add_parser( "write", help="Write text input into a shell.", description="Write text input into a persistent workspace shell.", epilog="Example:\n pyro workspace shell write WORKSPACE_ID SHELL_ID --input 'pwd'", formatter_class=_HelpFormatter, ) workspace_shell_write_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_shell_write_parser.add_argument( "shell_id", metavar="SHELL_ID", help="Persistent shell identifier returned by `workspace shell open`.", ) workspace_shell_write_parser.add_argument( "--input", required=True, help="Text to send to the shell.", ) workspace_shell_write_parser.add_argument( "--no-newline", action="store_true", help="Do not append a trailing newline after the provided input.", ) workspace_shell_write_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_shell_signal_parser = workspace_shell_subparsers.add_parser( "signal", help="Send a signal to a shell process group.", description="Send a control signal to a persistent workspace shell.", epilog="Example:\n pyro workspace shell signal WORKSPACE_ID SHELL_ID --signal INT", formatter_class=_HelpFormatter, ) workspace_shell_signal_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_shell_signal_parser.add_argument( "shell_id", metavar="SHELL_ID", help="Persistent shell identifier returned by `workspace shell open`.", ) workspace_shell_signal_parser.add_argument( "--signal", default="INT", choices=WORKSPACE_SHELL_SIGNAL_NAMES, help="Signal name to send to the shell process group.", ) workspace_shell_signal_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_shell_close_parser = workspace_shell_subparsers.add_parser( "close", help="Close a persistent shell.", description="Close a persistent workspace shell and release its PTY state.", epilog="Example:\n pyro workspace shell close WORKSPACE_ID SHELL_ID", formatter_class=_HelpFormatter, ) workspace_shell_close_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_shell_close_parser.add_argument( "shell_id", metavar="SHELL_ID", help="Persistent shell identifier returned by `workspace shell open`.", ) workspace_shell_close_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_service_parser = workspace_subparsers.add_parser( "service", help="Manage long-running services inside a workspace.", description=( "Start, inspect, and stop named long-running services inside one started workspace." ), epilog=dedent( """ Examples: pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace service start WORKSPACE_ID app --ready-file .ready --publish 8080 -- \ sh -lc 'touch .ready && python3 -m http.server 8080' pyro workspace service list WORKSPACE_ID pyro workspace service status WORKSPACE_ID app pyro workspace service logs WORKSPACE_ID app --tail-lines 50 pyro workspace service stop WORKSPACE_ID app Use `--ready-file` by default in the curated Debian environments. `--ready-command` remains available as an escape hatch. """ ), formatter_class=_HelpFormatter, ) workspace_service_subparsers = workspace_service_parser.add_subparsers( dest="workspace_service_command", required=True, metavar="SERVICE", ) workspace_service_start_parser = workspace_service_subparsers.add_parser( "start", help="Start one named long-running service.", description="Start a named service inside a started workspace with optional readiness.", epilog=dedent( """ Examples: pyro workspace service start WORKSPACE_ID app --ready-file .ready -- \ sh -lc 'touch .ready && while true; do sleep 60; done' pyro workspace service start WORKSPACE_ID app \ --ready-file .ready --publish 18080:8080 -- \ sh -lc 'touch .ready && python3 -m http.server 8080' pyro workspace service start WORKSPACE_ID app --secret-env API_TOKEN -- \ sh -lc 'test \"$API_TOKEN\" = \"expected\"; touch .ready; \ while true; do sleep 60; done' pyro workspace service start WORKSPACE_ID app --ready-command 'test -f .ready' -- \ sh -lc 'touch .ready && while true; do sleep 60; done' """ ), formatter_class=_HelpFormatter, ) workspace_service_start_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_service_start_parser.add_argument("service_name", metavar="SERVICE_NAME") workspace_service_start_parser.add_argument( "--cwd", default=WORKSPACE_GUEST_PATH, help="Service working directory. Relative values resolve inside `/workspace`.", ) workspace_service_start_parser.add_argument( "--ready-file", help="Mark the service ready once this workspace path exists.", ) workspace_service_start_parser.add_argument( "--ready-tcp", help="Mark the service ready once this HOST:PORT accepts guest-local TCP connections.", ) workspace_service_start_parser.add_argument( "--ready-http", help="Mark the service ready once this guest-local URL returns 2xx or 3xx.", ) workspace_service_start_parser.add_argument( "--ready-command", help="Escape hatch readiness probe command. Use typed readiness when possible.", ) workspace_service_start_parser.add_argument( "--ready-timeout-seconds", type=int, default=DEFAULT_SERVICE_READY_TIMEOUT_SECONDS, help="Maximum time to wait for readiness before failing the service start.", ) workspace_service_start_parser.add_argument( "--ready-interval-ms", type=int, default=DEFAULT_SERVICE_READY_INTERVAL_MS, help="Polling interval between readiness checks.", ) workspace_service_start_parser.add_argument( "--secret-env", action="append", default=[], metavar="SECRET[=ENV_VAR]", help="Expose one persisted workspace secret as an environment variable for this service.", ) workspace_service_start_parser.add_argument( "--publish", action="append", default=[], metavar="GUEST_PORT|HOST_PORT:GUEST_PORT", help=( "Publish one guest TCP port on 127.0.0.1. Requires workspace network policy " "`egress+published-ports`." ), ) workspace_service_start_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_service_start_parser.add_argument( "command_args", nargs="*", metavar="ARG", help="Service command and arguments. Prefix them with `--`.", ) workspace_service_list_parser = workspace_service_subparsers.add_parser( "list", help="List named services in one workspace.", description="List named services and their current states for one workspace.", epilog="Example:\n pyro workspace service list WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_service_list_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_service_list_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_service_status_parser = workspace_service_subparsers.add_parser( "status", help="Inspect one service.", description="Show state and readiness metadata for one named workspace service.", epilog="Example:\n pyro workspace service status WORKSPACE_ID app", formatter_class=_HelpFormatter, ) workspace_service_status_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_service_status_parser.add_argument("service_name", metavar="SERVICE_NAME") workspace_service_status_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_service_logs_parser = workspace_service_subparsers.add_parser( "logs", help="Read persisted service stdout and stderr.", description="Read service stdout and stderr without using `workspace logs`.", epilog="Example:\n pyro workspace service logs WORKSPACE_ID app --tail-lines 50", formatter_class=_HelpFormatter, ) workspace_service_logs_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_service_logs_parser.add_argument("service_name", metavar="SERVICE_NAME") workspace_service_logs_parser.add_argument( "--tail-lines", type=int, default=DEFAULT_SERVICE_LOG_TAIL_LINES, help="Maximum number of trailing lines to return from each service log stream.", ) workspace_service_logs_parser.add_argument( "--all", action="store_true", help="Return full stdout and stderr instead of tailing them.", ) workspace_service_logs_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_service_stop_parser = workspace_service_subparsers.add_parser( "stop", help="Stop one running service.", description="Stop one named workspace service with TERM then KILL fallback.", epilog="Example:\n pyro workspace service stop WORKSPACE_ID app", formatter_class=_HelpFormatter, ) workspace_service_stop_parser.add_argument("workspace_id", metavar="WORKSPACE_ID") workspace_service_stop_parser.add_argument("service_name", metavar="SERVICE_NAME") workspace_service_stop_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_status_parser = workspace_subparsers.add_parser( "status", help="Inspect one workspace.", description="Show workspace state, sizing, workspace path, and latest command metadata.", epilog="Example:\n pyro workspace status WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_status_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_status_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_logs_parser = workspace_subparsers.add_parser( "logs", help="Show command history for one workspace.", description=( "Show persisted command history, including stdout and stderr, " "for one workspace." ), epilog="Example:\n pyro workspace logs WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_logs_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_logs_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) workspace_delete_parser = workspace_subparsers.add_parser( "delete", help="Delete one workspace.", description="Stop the backing sandbox if needed and remove the workspace.", epilog="Example:\n pyro workspace delete WORKSPACE_ID", formatter_class=_HelpFormatter, ) workspace_delete_parser.add_argument( "workspace_id", metavar="WORKSPACE_ID", help="Persistent workspace identifier.", ) workspace_delete_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) doctor_parser = subparsers.add_parser( "doctor", help="Inspect runtime and host diagnostics.", description="Check host prerequisites and embedded runtime health before your first run.", epilog=dedent( """ Examples: pyro doctor pyro doctor --json """ ), formatter_class=_HelpFormatter, ) doctor_parser.add_argument( "--platform", default=DEFAULT_PLATFORM, help="Runtime platform to inspect.", ) doctor_parser.add_argument( "--json", action="store_true", help="Print structured JSON instead of human-readable output.", ) demo_parser = subparsers.add_parser( "demo", help="Run built-in demos.", description="Run built-in demos after the basic CLI validation path works.", epilog=dedent( """ Examples: pyro demo pyro demo --network pyro demo ollama --verbose """ ), formatter_class=_HelpFormatter, ) demo_subparsers = demo_parser.add_subparsers(dest="demo_command", metavar="DEMO") demo_parser.add_argument( "--network", action="store_true", help="Enable outbound guest networking for the deterministic demo.", ) ollama_parser = demo_subparsers.add_parser( "ollama", help="Run the Ollama MCP demo.", description="Run the Ollama tool-calling demo against the `vm_run` and lifecycle tools.", epilog=dedent( """ Example: pyro demo ollama --model llama3.2:3b --verbose """ ), formatter_class=_HelpFormatter, ) ollama_parser.add_argument( "--base-url", default=DEFAULT_OLLAMA_BASE_URL, help="OpenAI-compatible base URL for the Ollama server.", ) ollama_parser.add_argument( "--model", default=DEFAULT_OLLAMA_MODEL, help="Ollama model name to use for tool calling.", ) ollama_parser.add_argument( "-v", "--verbose", action="store_true", help="Print full tool loop output instead of only the summary.", ) return parser def _require_command(command_args: list[str]) -> str: if command_args and command_args[0] == "--": command_args = command_args[1:] if not command_args: raise ValueError("command is required after `--`") return shlex.join(command_args) def _parse_workspace_secret_option(value: str) -> dict[str, str]: name, sep, secret_value = value.partition("=") if sep == "" or name.strip() == "" or secret_value == "": raise ValueError("workspace secrets must use NAME=VALUE") return {"name": name.strip(), "value": secret_value} def _parse_workspace_secret_file_option(value: str) -> dict[str, str]: name, sep, file_path = value.partition("=") if sep == "" or name.strip() == "" or file_path.strip() == "": raise ValueError("workspace secret files must use NAME=PATH") return {"name": name.strip(), "file_path": file_path.strip()} def _parse_workspace_secret_env_options(values: list[str]) -> dict[str, str]: parsed: dict[str, str] = {} for raw_value in values: secret_name, sep, env_name = raw_value.partition("=") normalized_secret_name = secret_name.strip() if normalized_secret_name == "": raise ValueError("workspace secret env mappings must name a secret") normalized_env_name = env_name.strip() if sep != "" else normalized_secret_name if normalized_env_name == "": raise ValueError("workspace secret env mappings must name an environment variable") if normalized_secret_name in parsed: raise ValueError( f"workspace secret env mapping references {normalized_secret_name!r} more than once" ) parsed[normalized_secret_name] = normalized_env_name return parsed def _parse_workspace_publish_options(values: list[str]) -> list[dict[str, int | None]]: parsed: list[dict[str, int | None]] = [] for raw_value in values: candidate = raw_value.strip() if candidate == "": raise ValueError("published ports must not be empty") if ":" in candidate: raw_host_port, raw_guest_port = candidate.split(":", 1) try: host_port = int(raw_host_port) guest_port = int(raw_guest_port) except ValueError as exc: raise ValueError( "published ports must use GUEST_PORT or HOST_PORT:GUEST_PORT" ) from exc parsed.append({"host_port": host_port, "guest_port": guest_port}) else: try: guest_port = int(candidate) except ValueError as exc: raise ValueError( "published ports must use GUEST_PORT or HOST_PORT:GUEST_PORT" ) from exc parsed.append({"host_port": None, "guest_port": guest_port}) return parsed def main() -> None: args = _build_parser().parse_args() pyro = Pyro() if args.command == "env": if args.env_command == "list": list_payload: dict[str, Any] = { "catalog_version": DEFAULT_CATALOG_VERSION, "environments": pyro.list_environments(), } if bool(args.json): _print_json(list_payload) else: _print_env_list_human(list_payload) return if args.env_command == "pull": if bool(args.json): pull_payload = pyro.pull_environment(args.environment) _print_json(pull_payload) else: _print_phase("pull", phase="install", environment=args.environment) pull_payload = pyro.pull_environment(args.environment) _print_phase("pull", phase="ready", environment=args.environment) _print_env_detail_human(pull_payload, action="Pulled") return if args.env_command == "inspect": inspect_payload = pyro.inspect_environment(args.environment) if bool(args.json): _print_json(inspect_payload) else: _print_env_detail_human(inspect_payload, action="Environment") return if args.env_command == "prune": prune_payload = pyro.prune_environments() if bool(args.json): _print_json(prune_payload) else: _print_prune_human(prune_payload) return if args.command == "mcp": pyro.create_server().run(transport="stdio") return if args.command == "run": command = _require_command(args.command_args) if bool(args.json): try: result = pyro.run_in_vm( environment=args.environment, command=command, vcpu_count=args.vcpu_count, mem_mib=args.mem_mib, timeout_seconds=args.timeout_seconds, ttl_seconds=args.ttl_seconds, network=args.network, allow_host_compat=args.allow_host_compat, ) except Exception as exc: # noqa: BLE001 _print_json({"ok": False, "error": str(exc)}) raise SystemExit(1) from exc _print_json(result) else: vm_id: str | None = None try: _print_phase("run", phase="create", environment=args.environment) created = pyro.create_vm( environment=args.environment, vcpu_count=args.vcpu_count, mem_mib=args.mem_mib, ttl_seconds=args.ttl_seconds, network=args.network, allow_host_compat=args.allow_host_compat, ) vm_id = str(created["vm_id"]) _print_phase("run", phase="start", vm_id=vm_id) pyro.start_vm(vm_id) _print_phase("run", phase="execute", vm_id=vm_id) result = pyro.exec_vm(vm_id, command=command, timeout_seconds=args.timeout_seconds) except Exception as exc: # noqa: BLE001 if vm_id is not None: try: pyro.manager.delete_vm(vm_id, reason="run_vm_error_cleanup") except ValueError: pass print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_run_human(result) exit_code = int(result.get("exit_code", 1)) if exit_code != 0: raise SystemExit(exit_code) return if args.command == "workspace": if args.workspace_command == "create": secrets = [ *( _parse_workspace_secret_option(value) for value in getattr(args, "secret", []) ), *( _parse_workspace_secret_file_option(value) for value in getattr(args, "secret_file", []) ), ] payload = pyro.create_workspace( environment=args.environment, vcpu_count=args.vcpu_count, mem_mib=args.mem_mib, ttl_seconds=args.ttl_seconds, network_policy=getattr(args, "network_policy", "off"), allow_host_compat=args.allow_host_compat, seed_path=args.seed_path, secrets=secrets or None, ) if bool(args.json): _print_json(payload) else: _print_workspace_summary_human(payload, action="Workspace") return if args.workspace_command == "exec": command = _require_command(args.command_args) secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) if bool(args.json): try: payload = pyro.exec_workspace( args.workspace_id, command=command, timeout_seconds=args.timeout_seconds, secret_env=secret_env or None, ) except Exception as exc: # noqa: BLE001 _print_json({"ok": False, "error": str(exc)}) raise SystemExit(1) from exc _print_json(payload) else: try: payload = pyro.exec_workspace( args.workspace_id, command=command, timeout_seconds=args.timeout_seconds, secret_env=secret_env or None, ) except Exception as exc: # noqa: BLE001 print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_exec_human(payload) exit_code = int(payload.get("exit_code", 1)) if exit_code != 0: raise SystemExit(exit_code) return if args.workspace_command == "sync" and args.workspace_sync_command == "push": if bool(args.json): try: payload = pyro.push_workspace_sync( args.workspace_id, args.source_path, dest=args.dest, ) except Exception as exc: # noqa: BLE001 _print_json({"ok": False, "error": str(exc)}) raise SystemExit(1) from exc _print_json(payload) else: try: payload = pyro.push_workspace_sync( args.workspace_id, args.source_path, dest=args.dest, ) except Exception as exc: # noqa: BLE001 print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_sync_human(payload) return if args.workspace_command == "export": if bool(args.json): try: payload = pyro.export_workspace( args.workspace_id, args.path, output_path=args.output, ) except Exception as exc: # noqa: BLE001 _print_json({"ok": False, "error": str(exc)}) raise SystemExit(1) from exc _print_json(payload) else: try: payload = pyro.export_workspace( args.workspace_id, args.path, output_path=args.output, ) except Exception as exc: # noqa: BLE001 print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_export_human(payload) return if args.workspace_command == "diff": if bool(args.json): try: payload = pyro.diff_workspace(args.workspace_id) except Exception as exc: # noqa: BLE001 _print_json({"ok": False, "error": str(exc)}) raise SystemExit(1) from exc _print_json(payload) else: try: payload = pyro.diff_workspace(args.workspace_id) except Exception as exc: # noqa: BLE001 print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc _print_workspace_diff_human(payload) return if args.workspace_command == "file": if args.workspace_file_command == "list": try: payload = pyro.list_workspace_files( args.workspace_id, path=args.path, recursive=bool(args.recursive), ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_file_list_human(payload) return if args.workspace_file_command == "read": try: payload = pyro.read_workspace_file( args.workspace_id, args.path, max_bytes=args.max_bytes, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_file_read_human(payload) return if args.workspace_file_command == "write": try: payload = pyro.write_workspace_file( args.workspace_id, args.path, text=args.text, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_file_write_human(payload) return if args.workspace_command == "patch" and args.workspace_patch_command == "apply": try: payload = pyro.apply_workspace_patch( args.workspace_id, patch=args.patch, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_patch_human(payload) return if args.workspace_command == "snapshot": if args.workspace_snapshot_command == "create": try: payload = pyro.create_snapshot(args.workspace_id, args.snapshot_name) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_snapshot_human( payload, prefix="workspace-snapshot-create", ) return if args.workspace_snapshot_command == "list": try: payload = pyro.list_snapshots(args.workspace_id) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_snapshot_list_human(payload) return if args.workspace_snapshot_command == "delete": try: payload = pyro.delete_snapshot(args.workspace_id, args.snapshot_name) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: print( "Deleted workspace snapshot: " f"{str(payload.get('snapshot_name', 'unknown'))}" ) return if args.workspace_command == "reset": try: payload = pyro.reset_workspace( args.workspace_id, snapshot=args.snapshot, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_reset_human(payload) return if args.workspace_command == "stop": try: payload = pyro.stop_workspace(args.workspace_id) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_summary_human(payload, action="Stopped workspace") return if args.workspace_command == "start": try: payload = pyro.start_workspace(args.workspace_id) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_summary_human(payload, action="Started workspace") return if args.workspace_command == "disk": if args.workspace_disk_command == "export": try: payload = pyro.export_workspace_disk( args.workspace_id, output_path=args.output, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_disk_export_human(payload) return if args.workspace_disk_command == "list": try: payload = pyro.list_workspace_disk( args.workspace_id, path=args.path, recursive=bool(args.recursive), ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_disk_list_human(payload) return if args.workspace_disk_command == "read": try: payload = pyro.read_workspace_disk( args.workspace_id, args.path, max_bytes=args.max_bytes, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_disk_read_human(payload) return if args.workspace_command == "shell": if args.workspace_shell_command == "open": secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) try: payload = pyro.open_shell( args.workspace_id, cwd=args.cwd, cols=args.cols, rows=args.rows, secret_env=secret_env or None, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_shell_summary_human(payload, prefix="workspace-shell-open") return if args.workspace_shell_command == "read": try: payload = pyro.read_shell( args.workspace_id, args.shell_id, cursor=args.cursor, max_chars=args.max_chars, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_shell_read_human(payload) return if args.workspace_shell_command == "write": try: payload = pyro.write_shell( args.workspace_id, args.shell_id, input=args.input, append_newline=not bool(args.no_newline), ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_shell_summary_human(payload, prefix="workspace-shell-write") return if args.workspace_shell_command == "signal": try: payload = pyro.signal_shell( args.workspace_id, args.shell_id, signal_name=args.signal, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_shell_summary_human( payload, prefix="workspace-shell-signal", ) return if args.workspace_shell_command == "close": try: payload = pyro.close_shell(args.workspace_id, args.shell_id) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_shell_summary_human(payload, prefix="workspace-shell-close") return if args.workspace_command == "service": if args.workspace_service_command == "start": readiness_count = sum( value is not None for value in ( args.ready_file, args.ready_tcp, args.ready_http, args.ready_command, ) ) if readiness_count > 1: error = ( "choose at most one of --ready-file, --ready-tcp, " "--ready-http, or --ready-command" ) if bool(args.json): _print_json({"ok": False, "error": error}) else: print(f"[error] {error}", file=sys.stderr, flush=True) raise SystemExit(1) readiness: dict[str, Any] | None = None if args.ready_file is not None: readiness = {"type": "file", "path": args.ready_file} elif args.ready_tcp is not None: readiness = {"type": "tcp", "address": args.ready_tcp} elif args.ready_http is not None: readiness = {"type": "http", "url": args.ready_http} elif args.ready_command is not None: readiness = {"type": "command", "command": args.ready_command} command = _require_command(args.command_args) secret_env = _parse_workspace_secret_env_options(getattr(args, "secret_env", [])) published_ports = _parse_workspace_publish_options(getattr(args, "publish", [])) try: payload = pyro.start_service( args.workspace_id, args.service_name, command=command, cwd=args.cwd, readiness=readiness, ready_timeout_seconds=args.ready_timeout_seconds, ready_interval_ms=args.ready_interval_ms, secret_env=secret_env or None, published_ports=published_ports or None, ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_service_summary_human( payload, prefix="workspace-service-start", ) return if args.workspace_service_command == "list": try: payload = pyro.list_services(args.workspace_id) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_service_list_human(payload) return if args.workspace_service_command == "status": try: payload = pyro.status_service(args.workspace_id, args.service_name) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_service_summary_human( payload, prefix="workspace-service-status", ) return if args.workspace_service_command == "logs": try: payload = pyro.logs_service( args.workspace_id, args.service_name, tail_lines=args.tail_lines, all=bool(args.all), ) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_service_logs_human(payload) return if args.workspace_service_command == "stop": try: payload = pyro.stop_service(args.workspace_id, args.service_name) except Exception as exc: # noqa: BLE001 if bool(args.json): _print_json({"ok": False, "error": str(exc)}) else: print(f"[error] {exc}", file=sys.stderr, flush=True) raise SystemExit(1) from exc if bool(args.json): _print_json(payload) else: _print_workspace_service_summary_human( payload, prefix="workspace-service-stop", ) return if args.workspace_command == "status": payload = pyro.status_workspace(args.workspace_id) if bool(args.json): _print_json(payload) else: _print_workspace_summary_human(payload, action="Workspace") return if args.workspace_command == "logs": payload = pyro.logs_workspace(args.workspace_id) if bool(args.json): _print_json(payload) else: _print_workspace_logs_human(payload) return if args.workspace_command == "delete": payload = pyro.delete_workspace(args.workspace_id) if bool(args.json): _print_json(payload) else: print(f"Deleted workspace: {str(payload.get('workspace_id', 'unknown'))}") return if args.command == "doctor": payload = doctor_report(platform=args.platform) if bool(args.json): _print_json(payload) else: _print_doctor_human(payload) return if args.command == "demo" and args.demo_command == "ollama": try: result = run_ollama_tool_demo( base_url=args.base_url, model=args.model, verbose=args.verbose, log=lambda message: print(message, flush=True), ) except Exception as exc: # noqa: BLE001 print(f"[error] {exc}", flush=True) raise SystemExit(1) from exc exec_result = result["exec_result"] if not isinstance(exec_result, dict): raise RuntimeError("demo produced invalid execution result") print( f"[summary] exit_code={int(exec_result.get('exit_code', -1))} " f"fallback_used={bool(result.get('fallback_used'))} " f"execution_mode={str(exec_result.get('execution_mode', 'unknown'))}", flush=True, ) if args.verbose: print(f"[summary] stdout={str(exec_result.get('stdout', '')).strip()}", flush=True) return result = run_demo(network=bool(args.network)) _print_json(result)