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
2709 lines
107 KiB
Python
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)
|