pyro-mcp/src/pyro_mcp/cli.py
Thales Maciel ab02ae46c7 Add model-native workspace file operations
Remove shell-escaped file mutation from the stable workspace flow by adding explicit file and patch tools across the CLI, SDK, and MCP surfaces.

This adds workspace file list/read/write plus unified text patch application, backed by new guest and manager file primitives that stay scoped to started workspaces and /workspace only. Patch application is preflighted on the host, file writes stay text-only and bounded, and the existing diff/export/reset semantics remain intact.

The milestone also updates the 3.2.0 roadmap, public contract, docs, examples, and versioning, and includes focused coverage for the new helper module and dispatch paths.

Validation:
- uv lock
- UV_CACHE_DIR=.uv-cache make check
- UV_CACHE_DIR=.uv-cache make dist-check
- real guest-backed smoke for workspace file read, patch apply, exec, export, and delete
2026-03-12 22:03:25 -03:00

2709 lines
107 KiB
Python

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