help: regroup and tighten the CLI usage output

The launch verbs (claude/codex/opencode/shell) share one `run::Args`,
and every flag carried its full multi-sentence explanation as the
*short* help. clap renders the first doc paragraph as `-h` text, so
`-h` was a wall: 13 flags, each 3-8 lines of run-on prose. Worse, the
crate built clap without `wrap_help`, so `--help` printed each long
paragraph as one unwrapped line — the terminal soft-wrapped it, but
copy/paste and narrow widths showed a single giant line per flag.

Split short vs long help, and group the flags:

  - Every option now leads with a terse one-line summary (the `-h`
    text); the original detail is kept as follow-on paragraphs that
    only show under `--help`.
  - Flags are bucketed with `help_heading`: Sandbox resources /
    GitHub access / Mounts & ports / Network egress / Image.
  - Added the `wrap_help` clap feature so `--help` prose wraps to the
    terminal width instead of emitting one line per paragraph.
  - Real value names: <GIB>, <N>, <HOST[:GUEST]>, <[BIND:]HOST:GUEST>,
    <IP|CIDR>, <OWNER/REPO>, <REF>.
  - `--help` gains an Examples block, a host<->guest Networking
    cheatsheet, and an Environment section (vars verified against the
    code).

Top level: wire up `-V/--version` (it errored before), add a
"Getting started" footer, make the subcommand descriptions terse and
parallel, and drop the redundant "See `clipboard --help`" line.
setup/pull `--image` get the same terse-first-line split.

Pure presentation change — no flag, env var, or behaviour added or
removed. `cargo test -p agent-vm`: 123 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evgeny Boger 2026-05-30 19:08:11 +00:00
parent 63cab08d82
commit f881e073c1
6 changed files with 148 additions and 62 deletions

11
Cargo.lock generated
View file

@ -639,6 +639,7 @@ dependencies = [
"anstyle",
"clap_lex",
"strsim",
"terminal_size",
]
[[package]]
@ -4627,6 +4628,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "terminal_size"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "thiserror"
version = "1.0.69"

View file

@ -13,7 +13,10 @@ path = "src/main.rs"
[dependencies]
microsandbox = { path = "../../vendor/microsandbox/crates/microsandbox" }
tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "time"] }
clap = { version = "4.5", features = ["derive", "env"] }
# `wrap_help` makes clap detect the terminal width and wrap long help
# text to it; without it the multi-paragraph `--help` prose renders as
# unbroken one-line-per-paragraph walls.
clap = { version = "4.5", features = ["derive", "env", "wrap_help"] }
anyhow = "1.0"
base64 = "0.22"
sha2 = "0.10"

View file

@ -18,8 +18,23 @@ mod setup;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
// Shown under the top-level `agent-vm --help`, after the command list.
const TOP_AFTER_HELP: &str = "\
Getting started:
agent-vm setup fetch and verify the base image (run once first)
cd ~/your-project
agent-vm claude launch in this project or codex / opencode / shell
claude, codex, opencode and shell share the same options;
see `agent-vm claude --help` for mounts, ports, networking and credentials.";
#[derive(Parser)]
#[command(name = "agent-vm", about = "Sandboxed VMs for AI coding agents.")]
#[command(
name = "agent-vm",
version,
about = "Sandboxed microVMs for AI coding agents.",
after_help = TOP_AFTER_HELP
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
@ -27,26 +42,25 @@ struct Cli {
#[derive(Subcommand)]
enum Cmd {
/// Build (and verify) the agent-vm base image.
/// Pull and verify the base image (run once first).
Setup(setup::Args),
/// Pull the latest image from the registry into the microsandbox cache.
/// Refresh the cached base image.
Pull(pull::Args),
/// Launch Claude Code in a sandbox mounted at the project's host path.
/// Launch Claude Code in a per-project sandbox.
Claude(run::Args),
/// Launch Codex CLI in a sandbox mounted at the project's host path.
/// Launch Codex CLI in a per-project sandbox.
Codex(run::Args),
/// Launch OpenCode in a sandbox mounted at the project's host path.
/// Launch OpenCode in a per-project sandbox.
Opencode(run::Args),
/// Open a bash shell in a sandbox mounted at the project's host path.
/// Open a bash shell in a per-project sandbox.
Shell(run::Args),
/// Exchange a string between the host and the per-project sandbox.
/// See `agent-vm clipboard --help`.
/// Exchange a string between the host and the sandbox.
Clipboard(clipboard::Args),
/// Internal: invoked by msb's interceptor hook for matched OAuth

View file

@ -24,11 +24,12 @@ use microsandbox::{Sandbox, sandbox::PullPolicy};
#[derive(ClapArgs)]
pub struct Args {
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm-template:latest` or the value of
/// `AGENT_VM_IMAGE_TAG`. Use a timestamped tag
/// Override the image reference.
///
/// Defaults to `ghcr.io/wirenboard/agent-vm-template:latest` or the
/// value of `AGENT_VM_IMAGE_TAG`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a specific build.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
image: Option<String>,
}

View file

@ -94,32 +94,77 @@ impl Agent {
}
}
// Footer shown under `-h` (the short summary). A few high-value
// examples plus a pointer to `--help`. Printed verbatim by clap, so
// this is exactly what the user sees. Kept command-agnostic: all four
// launch verbs (claude/codex/opencode/shell) share this `Args`.
const LAUNCH_AFTER_HELP: &str = "\
Examples:
agent-vm claude launch Claude Code in the current project
agent-vm shell open a bash shell instead
agent-vm claude -p 8080:3000 publish guest :3000 to host 127.0.0.1:8080
agent-vm claude -- --model opus forward args to the agent (after --)
Trailing args go to the agent. Run with --help for networking, security, and env details.";
// Fuller footer shown under `--help`. Same command-agnostic constraint.
const LAUNCH_AFTER_LONG_HELP: &str = "\
Examples:
agent-vm claude launch in the current project
agent-vm shell open a bash shell instead
agent-vm shell -- -c 'cargo test' run one command, then exit
agent-vm claude -- --model opus --resume forward args to the agent
agent-vm claude --memory 8 --cpus 4 a bigger sandbox
agent-vm claude --mount ~/ref -p 3000:3000 extra mount + publish a port
agent-vm claude --repo owner/other-repo widen the GitHub allow-list
Networking (deny-by-default; flags compose):
--publish [BIND:]HOST:GUEST host guest open an inbound port
--auto-publish guest host mirror every guest listener to loopback
--allow-egress IP|CIDR guest IP/LAN open one address or subnet
--allow-lan guest LAN open the whole private range
--allow-host guest host reach services on host 127.0.0.1
Environment:
AGENT_VM_MEMORY_GIB / AGENT_VM_CPUS same as --memory / --cpus
AGENT_VM_IMAGE_TAG same as --image
AGENT_VM_PROFILE print per-phase boot timings
AGENT_VM_DEBUG_CONFIG dump the SandboxConfig JSON before boot
AGENT_VM_NO_CHROME_MCP skip the Chrome DevTools MCP setup
RUST_LOG tracing filter (e.g. agent_vm=debug)";
#[derive(ClapArgs)]
#[command(after_help = LAUNCH_AFTER_HELP, after_long_help = LAUNCH_AFTER_LONG_HELP)]
pub struct Args {
/// Sandbox memory in GiB.
#[arg(long, env = "AGENT_VM_MEMORY_GIB", default_value_t = 2)]
/// Sandbox memory, in GiB.
#[arg(long, env = "AGENT_VM_MEMORY_GIB", default_value_t = 2,
value_name = "GIB", help_heading = "Sandbox resources")]
memory: u32,
/// vCPU count for the sandbox.
#[arg(long, env = "AGENT_VM_CPUS", default_value_t = 2)]
#[arg(long, env = "AGENT_VM_CPUS", default_value_t = 2,
value_name = "N", help_heading = "Sandbox resources")]
cpus: u8,
/// Don't inject host gh/git credentials into the guest. With this
/// set, no gh auth flows through the proxy and the guest agent
/// can't `git push` / `gh pr create` etc. Useful for one-off
/// Don't inject host gh/git credentials into the guest.
///
/// With this set, no gh auth flows through the proxy and the guest
/// agent can't `git push` / `gh pr create` etc. Useful for one-off
/// throwaway sessions on a repo you don't trust the agent with.
#[arg(long = "no-git", default_value_t = false)]
#[arg(long = "no-git", default_value_t = false, help_heading = "GitHub access")]
no_git: bool,
/// Add a GitHub `owner/repo` slug to the per-launch allow-list
/// (repeatable). The cwd's `git remote -v` GitHub entries are
/// always included; use this to widen for cross-repo work.
#[arg(long = "repo")]
/// Add a repo to the GitHub allow-list (repeatable).
///
/// The cwd's `git remote -v` GitHub entries are always included;
/// use this to widen the allow-list for cross-repo work.
#[arg(long = "repo", value_name = "OWNER/REPO", help_heading = "GitHub access")]
repo: Vec<String>,
/// Extra host directories to bind into the guest. Format:
/// `HOST[:GUEST]`. `GUEST` defaults to `HOST` (mirror at the
/// same absolute path). Repeatable.
/// Bind an extra host directory into the guest (repeatable).
///
/// Format `HOST[:GUEST]`; `GUEST` defaults to `HOST` (mirror at the
/// same absolute path).
///
/// Each `--mount` consumes one virtio-fs device. The microsandbox
/// runtime enables msb_krun's userspace split irqchip, which on
@ -129,24 +174,27 @@ pub struct Args {
/// (shared with rootfs, network, vsock, console, and any
/// `--volume` disks — call it ~210 user mounts for the common
/// config). You can stop worrying about it for typical workloads.
#[arg(long = "mount")]
#[arg(long = "mount", value_name = "HOST[:GUEST]", help_heading = "Mounts & ports")]
mount: Vec<String>,
/// Publish a guest TCP port to the host. Format:
/// `[HOST_BIND:]HOST_PORT:GUEST_PORT` (docker-style). HOST_BIND
/// defaults to `127.0.0.1` — pass `0.0.0.0:HOST_PORT:GUEST_PORT`
/// to expose on every host interface. Repeatable.
/// Publish a guest TCP port to the host, docker-style (repeatable).
///
/// Format `[HOST_BIND:]HOST_PORT:GUEST_PORT`. HOST_BIND defaults to
/// `127.0.0.1` — pass `0.0.0.0:HOST_PORT:GUEST_PORT` to expose on
/// every host interface.
///
/// The guest service must listen on `0.0.0.0` (or the assigned
/// guest IP from `MSB_NET_IPV4`); a bare `127.0.0.1` bind inside
/// the guest is not reachable because the smoltcp dial target is
/// the guest's assigned VLAN address, not loopback.
#[arg(long = "publish", short = 'p')]
#[arg(long = "publish", short = 'p', value_name = "[BIND:]HOST:GUEST",
help_heading = "Mounts & ports")]
publish: Vec<String>,
/// Lima-style host ← guest auto-port-forwarding. The runtime
/// polls `/proc/net/tcp{,6}` inside the guest every ~2s and
/// mirrors each detected wildcard (`0.0.0.0`/`[::]`) OR
/// Auto-forward every guest listener onto the host (Lima-style).
///
/// The runtime polls `/proc/net/tcp{,6}` inside the guest every ~2s
/// and mirrors each detected wildcard (`0.0.0.0`/`[::]`) OR
/// loopback (`127.0.0.1`/`[::1]`) TCP LISTEN socket onto a
/// host listener at `127.0.0.1:<same port>` (or an ephemeral
/// host port if the preferred one is taken). Loopback-only
@ -162,14 +210,14 @@ pub struct Args {
/// other processes on the host's loopback. If you don't want
/// that, omit `--auto-publish` and use `--publish` to expose
/// only the specific ports you mean to share.
#[arg(long = "auto-publish", default_value_t = false)]
#[arg(long = "auto-publish", default_value_t = false, help_heading = "Mounts & ports")]
auto_publish: bool,
/// Punch a hole through the default egress policy for one IP
/// or CIDR. Repeatable. Examples:
/// `--allow-egress 10.100.1.75` (single host)
/// `--allow-egress 10.100.1.0/24` (CIDR)
/// `--allow-egress fd00::1/128` (IPv6)
/// Allow guest egress to one IP or CIDR (repeatable).
///
/// Examples: `--allow-egress 10.100.1.75` (single host),
/// `--allow-egress 10.100.1.0/24` (CIDR),
/// `--allow-egress fd00::1/128` (IPv6).
///
/// The default policy (`NetworkPolicy::public_only`) only
/// allows DNS and the `Public` destination group, so RFC1918
@ -178,10 +226,12 @@ pub struct Args {
/// ECONNREFUSED. Use this flag to reach a specific dev box on
/// the same LAN as the host. Use `--allow-lan` instead if you
/// want to open the entire Private group at once.
#[arg(long = "allow-egress")]
#[arg(long = "allow-egress", value_name = "IP|CIDR", help_heading = "Network egress")]
allow_egress: Vec<String>,
/// Switch the egress policy from `public_only` to `non_local`
/// Allow guest egress to the whole private LAN.
///
/// Switches the egress policy from `public_only` to `non_local`
/// — adds the entire `DestinationGroup::Private` (10/8,
/// 172.16/12, 192.168/16, 100.64/10, fc00::/7) to the allow
/// list. Coarser than `--allow-egress <CIDR>`; useful for
@ -191,11 +241,12 @@ pub struct Args {
/// Security note: a compromised in-guest process gets full
/// access to every device on your LAN with this flag. Prefer
/// `--allow-egress <CIDR>` for production-ish uses.
#[arg(long = "allow-lan", default_value_t = false)]
#[arg(long = "allow-lan", default_value_t = false, help_heading = "Network egress")]
allow_lan: bool,
/// Allow the guest to reach services bound to `127.0.0.1` on the
/// host. The smoltcp stack rewrites the per-sandbox gateway IP
/// Allow the guest to reach the host's 127.0.0.1 services.
///
/// The smoltcp stack rewrites the per-sandbox gateway IP
/// (resolves as `host.microsandbox.internal` inside the guest)
/// to host's loopback, so e.g. a dev server bound to
/// `127.0.0.1:8080` on the host becomes reachable from the guest
@ -209,22 +260,27 @@ pub struct Args {
/// listening on a TCP port — becomes reachable from a possibly-
/// compromised in-guest process. Use only when you actually need
/// it.
#[arg(long = "allow-host", default_value_t = false)]
#[arg(long = "allow-host", default_value_t = false, help_heading = "Network egress")]
allow_host: bool,
/// Override the OCI image reference. Default:
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Use a timestamped tag
/// (`...:YYYY-MM-DDTHH`) to pin a reproducible image.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
/// Override the OCI image reference.
///
/// Default: `ghcr.io/wirenboard/agent-vm-template:latest`. Use a
/// timestamped tag (`...:YYYY-MM-DDTHH`) to pin a reproducible image.
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF", help_heading = "Image")]
image: Option<String>,
/// Don't HEAD the registry for a newer manifest digest at
/// launch (skips the "==> A newer image is available …" banner).
/// Useful in CI and on flaky networks.
#[arg(long = "no-update-check", default_value_t = false)]
/// Skip the launch-time registry update check.
///
/// Don't HEAD the registry for a newer manifest digest (skips the
/// "==> A newer image is available …" banner). Useful in CI and on
/// flaky networks.
#[arg(long = "no-update-check", default_value_t = false, help_heading = "Image")]
no_update_check: bool,
/// Args forwarded verbatim to the in-sandbox agent command. Use `--` if
/// Args passed verbatim to the agent; use -- before any agent flags.
///
/// Forwarded verbatim to the in-sandbox agent command. Use `--` if
/// any argument starts with `-` to keep clap from claiming it.
#[arg(trailing_var_arg = true, allow_hyphen_values = true, num_args = 0..)]
agent_args: Vec<String>,

View file

@ -21,12 +21,13 @@ pub struct Args {
#[arg(long)]
no_verify: bool,
/// Override the image reference. Defaults to
/// `ghcr.io/wirenboard/agent-vm-template:latest`. Source-checkout users
/// who built a local image can point at it
/// Override the image reference.
///
/// Defaults to `ghcr.io/wirenboard/agent-vm-template:latest`.
/// Source-checkout users who built a local image can point at it
/// (`--image localhost:5000/agent-vm-template:latest`) — agent-vm
/// detects local registries and uses plain HTTP.
#[arg(long, env = "AGENT_VM_IMAGE_TAG")]
#[arg(long, env = "AGENT_VM_IMAGE_TAG", value_name = "REF")]
image: Option<String>,
}