Polish onboarding and CLI help

This commit is contained in:
Thales Maciel 2026-03-09 21:12:56 -03:00
parent 38b6aeba68
commit b2ea56db4c
7 changed files with 561 additions and 58 deletions

22
CHANGELOG.md Normal file
View file

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

102
README.md
View file

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

61
docs/first-run.md Normal file
View file

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

View file

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

View file

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

View file

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

View file

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