From b2ea56db4c4b2cab714f6b111c395477165725f0 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Mon, 9 Mar 2026 21:12:56 -0300 Subject: [PATCH] Polish onboarding and CLI help --- CHANGELOG.md | 22 ++++ README.md | 102 ++++++++++++++- docs/first-run.md | 61 +++++++++ docs/install.md | 81 +++++++++--- docs/integrations.md | 3 + src/pyro_mcp/cli.py | 302 +++++++++++++++++++++++++++++++++++++------ tests/test_cli.py | 48 ++++++- 7 files changed, 561 insertions(+), 58 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/first-run.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d588825 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable user-visible changes to `pyro-mcp` are documented here. + +## 2.0.0 + +- Made guest execution fail closed by default; host compatibility execution now requires + explicit opt-in with `--allow-host-compat` or `allow_host_compat=True`. +- Switched the main CLI commands to human-readable output by default and kept `--json` + for structured output. +- Added default sizing of `1 vCPU / 1024 MiB` across the CLI, Python SDK, and MCP tools. +- Unified environment cache resolution across `pyro`, `Pyro`, and `pyro doctor`. +- Kept the stable environment-first contract centered on `vm_run`, `pyro run`, and + curated OCI-published environments. + +## 1.0.0 + +- Shipped the first stable public `pyro` CLI, `Pyro` SDK, and MCP server contract. +- Replaced the old bundled-profile model with curated named environments. +- Switched distribution to a thin Python package plus official OCI environment artifacts. +- Published the initial official environment catalog on public Docker Hub. +- Added first-party environment pull, inspect, prune, and one-shot run flows. diff --git a/README.md b/README.md index 911c0a6..bdfb26a 100644 --- a/README.md +++ b/README.md @@ -11,26 +11,110 @@ It exposes the same runtime in three public forms: ## Start Here - Install: [docs/install.md](docs/install.md) +- First run transcript: [docs/first-run.md](docs/first-run.md) - Host requirements: [docs/host-requirements.md](docs/host-requirements.md) - Integration targets: [docs/integrations.md](docs/integrations.md) - Public contract: [docs/public-contract.md](docs/public-contract.md) - Troubleshooting: [docs/troubleshooting.md](docs/troubleshooting.md) +- Changelog: [CHANGELOG.md](CHANGELOG.md) -## Public UX +## Supported Hosts -Primary install/run path: +Supported today: + +- Linux x86_64 +- Python 3.12+ +- `uv` +- `/dev/kvm` + +Optional for outbound guest networking: + +- `ip` +- `nft` or `iptables` +- privilege to create TAP devices and configure NAT + +Not supported today: + +- macOS +- Windows +- Linux hosts without working KVM at `/dev/kvm` + +If you do not already have `uv`, install it first: ```bash -uvx --from pyro-mcp pyro mcp serve +python -m pip install uv ``` -Installed package path: +## 5-Minute Evaluation + +Use the package directly without a manual install: + +### 1. Check the host ```bash -pyro mcp serve +uvx --from pyro-mcp pyro doctor ``` -The public user-facing interface is `pyro` and `Pyro`. +Expected success signals: + +```bash +Platform: linux-x86_64 +Runtime: PASS +KVM: exists=yes readable=yes writable=yes +Environment cache: /home/you/.cache/pyro-mcp/environments +Capabilities: vm_boot=yes guest_exec=yes guest_network=yes +Networking: tun=yes ip_forward=yes +``` + +### 2. Inspect the catalog and pull the default environment + +```bash +uvx --from pyro-mcp pyro env list +``` + +Expected output: + +```bash +Catalog version: 2.0.0 +debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. +debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. +debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. +``` + +```bash +uvx --from pyro-mcp pyro env pull debian:12 +``` + +### 3. Run one command in a guest + +```bash +uvx --from pyro-mcp pyro run debian:12 -- git --version +``` + +Expected success signals: + +```bash +git version ... +[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... +``` + +### 4. Optional demos + +```bash +uvx --from pyro-mcp pyro demo +uvx --from pyro-mcp pyro demo --network +``` + +If you prefer a fuller copy-pasteable transcript, see [docs/first-run.md](docs/first-run.md). + +## Public Interfaces + +The public user-facing interface is `pyro` and `Pyro`. After the CLI validation path works, you can choose one of three surfaces: + +- `pyro` for direct CLI usage +- `from pyro_mcp import Pyro` for Python orchestration +- `pyro mcp serve` for MCP clients + `Makefile` targets are contributor conveniences for this repository and are not the primary product UX. ## Official Environments @@ -86,6 +170,12 @@ pyro doctor --json It fails closed when guest boot or guest exec is unavailable. Use `--allow-host-compat` only if you explicitly want host execution. +Run the MCP server after the CLI path above works: + +```bash +pyro mcp serve +``` + Run the deterministic demo: ```bash diff --git a/docs/first-run.md b/docs/first-run.md new file mode 100644 index 0000000..e64d9cd --- /dev/null +++ b/docs/first-run.md @@ -0,0 +1,61 @@ +# First Run Transcript + +This is the intended evaluator path for a first successful run on a supported host. +Copy the commands as-is. Paths and timing values will differ on your machine. + +## 1. Verify the host + +```bash +$ uvx --from pyro-mcp pyro doctor +Platform: linux-x86_64 +Runtime: PASS +KVM: exists=yes readable=yes writable=yes +Environment cache: /home/you/.cache/pyro-mcp/environments +Capabilities: vm_boot=yes guest_exec=yes guest_network=yes +Networking: tun=yes ip_forward=yes +``` + +## 2. Inspect the catalog + +```bash +$ uvx --from pyro-mcp pyro env list +Catalog version: 2.0.0 +debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. +debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. +debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. +``` + +## 3. Pull the default environment + +```bash +$ uvx --from pyro-mcp pyro env pull debian:12 +Pulled: debian:12 +Version: 1.0.0 +Distribution: debian 12 +Installed: yes +Cache dir: /home/you/.cache/pyro-mcp/environments +Default packages: bash, coreutils, git +Install dir: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0 +Install manifest: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/environment.json +Kernel image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/vmlinux +Rootfs image: /home/you/.cache/pyro-mcp/environments/linux-x86_64/debian_12-1.0.0/rootfs.ext4 +OCI source: registry-1.docker.io/thalesmaciel/pyro-environment-debian-12:1.0.0 +``` + +## 4. Run one command in a guest + +```bash +$ uvx --from pyro-mcp pyro run debian:12 -- git --version +git version ... +[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... +``` + +## 5. Optional next steps + +```bash +$ uvx --from pyro-mcp pyro demo +$ uvx --from pyro-mcp pyro mcp serve +``` + +If `pyro doctor` reports `Runtime: FAIL`, or if the `pyro run` summary does not show +`execution_mode=guest_vsock`, stop and use [troubleshooting.md](troubleshooting.md). diff --git a/docs/install.md b/docs/install.md index 6c69183..e8ebc31 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,59 +1,110 @@ # Install -## Requirements +## Support Matrix -- Linux x86_64 host +Supported today: + +- Linux x86_64 - Python 3.12+ - `uv` - `/dev/kvm` -If you want outbound guest networking: +Optional for outbound guest networking: - `ip` - `nft` or `iptables` - privilege to create TAP devices and configure NAT -## Fastest Start +Not supported today: -Run the MCP server directly from the package without a manual install: +- macOS +- Windows +- Linux hosts without working KVM at `/dev/kvm` + +If you do not already have `uv`, install it first: ```bash -uvx --from pyro-mcp pyro mcp serve +python -m pip install uv ``` -Prefetch the default official environment: +## Fastest Evaluation Path + +Use the package directly without a manual install: + +### 1. Check the host first + +```bash +uvx --from pyro-mcp pyro doctor +``` + +Expected success signals: + +```bash +Platform: linux-x86_64 +Runtime: PASS +KVM: exists=yes readable=yes writable=yes +Environment cache: /home/you/.cache/pyro-mcp/environments +Capabilities: vm_boot=yes guest_exec=yes guest_network=yes +Networking: tun=yes ip_forward=yes +``` + +If `Runtime: FAIL`, stop here and use [troubleshooting.md](troubleshooting.md). + +### 2. Inspect the catalog + +```bash +uvx --from pyro-mcp pyro env list +``` + +Expected output: + +```bash +Catalog version: 2.0.0 +debian:12 [installed|not installed] Debian 12 environment with Git preinstalled for common agent workflows. +debian:12-base [installed|not installed] Minimal Debian 12 environment for shell and core Unix tooling. +debian:12-build [installed|not installed] Debian 12 environment with Git and common build tools preinstalled. +``` + +### 3. Pull the default environment ```bash uvx --from pyro-mcp pyro env pull debian:12 ``` -Run one command in a curated environment: +### 4. Run one command in a guest ```bash uvx --from pyro-mcp pyro run debian:12 -- git --version ``` -Inspect the official environment catalog: +Expected success signals: ```bash -uvx --from pyro-mcp pyro env list +git version ... +[run] environment=debian:12 execution_mode=guest_vsock exit_code=0 duration_ms=... ``` +If guest execution is unavailable, the command fails unless you explicitly pass +`--allow-host-compat`. + +For a fuller copy-pasteable transcript, see [first-run.md](first-run.md). + ## Installed CLI ```bash uv tool install pyro-mcp pyro --version +pyro doctor pyro env list pyro env pull debian:12 -pyro env inspect debian:12 -pyro doctor pyro run debian:12 -- git --version ``` -`pyro run` defaults to `1 vCPU / 1024 MiB`. -If guest execution is unavailable, the command fails unless you explicitly pass -`--allow-host-compat`. +After the CLI path works, you can move on to: + +- MCP: `pyro mcp serve` +- Python SDK: `from pyro_mcp import Pyro` +- Demos: `pyro demo` or `pyro demo --network` ## Contributor Clone diff --git a/docs/integrations.md b/docs/integrations.md index 31398c9..1239fdd 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -2,6 +2,9 @@ These are the main ways to integrate `pyro-mcp` into an LLM application. +Use this page after you have already validated the host and guest execution through the +CLI path in [install.md](install.md) or [first-run.md](first-run.md). + ## Recommended Default Use `vm_run` first. diff --git a/src/pyro_mcp/cli.py b/src/pyro_mcp/cli.py index 3a507c4..676ff53 100644 --- a/src/pyro_mcp/cli.py +++ b/src/pyro_mcp/cli.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import json import sys +from textwrap import dedent from typing import Any from pyro_mcp import __version__ @@ -142,55 +143,284 @@ def _print_doctor_human(payload: dict[str, Any]) -> None: print(f"- {issue}") +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="pyro CLI for curated ephemeral Linux environments." + description=( + "Run ephemeral Firecracker microVM workflows from the CLI on supported " + "Linux x86_64 KVM hosts." + ), + epilog=dedent( + """ + Suggested first run: + pyro doctor + pyro env pull debian:12 + pyro run debian:12 -- git --version + + 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) + subparsers = parser.add_subparsers(dest="command", required=True, metavar="COMMAND") - env_parser = subparsers.add_parser("env", help="Inspect and manage curated environments.") - env_subparsers = env_parser.add_subparsers(dest="env_command", required=True) - list_parser = env_subparsers.add_parser("list", help="List official environments.") - list_parser.add_argument("--json", action="store_true") + 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.", + epilog="Example:\n pyro env pull debian:12", + 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.", ) - pull_parser.add_argument("environment") - pull_parser.add_argument("--json", action="store_true") - inspect_parser = env_subparsers.add_parser("inspect", help="Inspect one environment.") - inspect_parser.add_argument("environment") - inspect_parser.add_argument("--json", action="store_true") - prune_parser = env_subparsers.add_parser("prune", help="Delete stale cached environments.") - prune_parser.add_argument("--json", action="store_true") - mcp_parser = subparsers.add_parser("mcp", help="Run the MCP server.") - mcp_subparsers = mcp_parser.add_subparsers(dest="mcp_command", required=True) - mcp_subparsers.add_parser("serve", help="Run the MCP server over stdio.") + 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 - run_parser = subparsers.add_parser("run", help="Run one command inside an ephemeral VM.") - run_parser.add_argument("environment") - run_parser.add_argument("--vcpu-count", type=int, default=DEFAULT_VCPU_COUNT) - run_parser.add_argument("--mem-mib", type=int, default=DEFAULT_MEM_MIB) - run_parser.add_argument("--timeout-seconds", type=int, default=30) - run_parser.add_argument("--ttl-seconds", type=int, default=600) - run_parser.add_argument("--network", action="store_true") - run_parser.add_argument("--allow-host-compat", action="store_true") - run_parser.add_argument("--json", action="store_true") - run_parser.add_argument("command_args", nargs="*") + Use this from an MCP client config after the CLI evaluation path works. + """ + ), + formatter_class=_HelpFormatter, + ) - doctor_parser = subparsers.add_parser("doctor", help="Inspect runtime and host diagnostics.") - doctor_parser.add_argument("--platform", default=DEFAULT_PLATFORM) - doctor_parser.add_argument("--json", action="store_true") + 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 -- git ls-remote https://github.com/octocat/Hello-World.git + """ + ), + 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`." + ), + ) - demo_parser = subparsers.add_parser("demo", help="Run built-in demos.") - demo_subparsers = demo_parser.add_subparsers(dest="demo_command") - demo_parser.add_argument("--network", action="store_true") - ollama_parser = demo_subparsers.add_parser("ollama", help="Run the Ollama MCP demo.") - ollama_parser.add_argument("--base-url", default=DEFAULT_OLLAMA_BASE_URL) - ollama_parser.add_argument("--model", default=DEFAULT_OLLAMA_MODEL) - ollama_parser.add_argument("-v", "--verbose", action="store_true") + 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index f862f93..7891b51 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,13 +3,59 @@ from __future__ import annotations import argparse import json import sys -from typing import Any +from typing import Any, cast import pytest import pyro_mcp.cli as cli +def _subparser_choice(parser: argparse.ArgumentParser, name: str) -> argparse.ArgumentParser: + subparsers = getattr(parser, "_subparsers", None) + if subparsers is None: + raise AssertionError("parser does not define subparsers") + group_actions = cast(list[Any], subparsers._group_actions) # noqa: SLF001 + if not group_actions: + raise AssertionError("parser subparsers are empty") + choices = cast(dict[str, argparse.ArgumentParser], group_actions[0].choices) + return choices[name] + + +def test_cli_help_guides_first_run() -> None: + parser = cli._build_parser() + help_text = parser.format_help() + + assert "Suggested first run:" in help_text + assert "pyro doctor" in help_text + assert "pyro env pull debian:12" in help_text + assert "pyro run debian:12 -- git --version" in help_text + assert "Use `pyro mcp serve` only after the CLI validation path works." in help_text + + +def test_cli_subcommand_help_includes_examples_and_guidance() -> None: + parser = cli._build_parser() + + run_help = _subparser_choice(parser, "run").format_help() + assert "pyro run debian:12 -- git --version" in run_help + assert "Opt into host-side compatibility execution" in run_help + assert "Enable outbound guest networking" in run_help + + env_help = _subparser_choice(_subparser_choice(parser, "env"), "pull").format_help() + assert "Environment name from `pyro env list`" in env_help + assert "pyro env pull debian:12" in env_help + + doctor_help = _subparser_choice(parser, "doctor").format_help() + assert "Check host prerequisites and embedded runtime health" in doctor_help + assert "pyro doctor --json" in doctor_help + + demo_help = _subparser_choice(parser, "demo").format_help() + assert "pyro demo ollama --verbose" in demo_help + + mcp_help = _subparser_choice(_subparser_choice(parser, "mcp"), "serve").format_help() + assert "Expose pyro tools over stdio for an MCP client." in mcp_help + assert "Use this from an MCP client config after the CLI evaluation path works." in mcp_help + + def test_cli_run_prints_json( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],