Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ sign-off trailer on every commit in a PR.
|---|---|---|
| unit | `just test` | nothing |
| race | `just race` | nothing |
| TUI model tests | `just test-pkg internal/tui` | nothing |
| TUI model tests | `just test-pkg tui` | nothing |
| e2e | `just e2e` | KVM, network, ~15 minutes |

A change to `internal/core`, `internal/sshx`, `internal/cloudinit`,
Expand Down
173 changes: 60 additions & 113 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,143 +1,90 @@
# stoat

A terminal UI and CLI for running local QEMU VMs on Linux. No libvirt, no daemon, one binary.
Stoat is a CLI for running local QEMU virtual machines, built for people and
AI agents. Use its TUI for interactive work, JSON CLI output for automation,
or MCP tools from an agent.

## What it does
It manages disposable Alpine sessions and persistent Linux guests through
plain TOML files. One Go binary starts QEMU directly.

- Boots an Alpine live VM that comes up already networked and `ssh`-reachable: an apkovl overlay is baked into the boot, so there's no `setup-alpine` step to get in.
- Also runs Ubuntu, Debian, Fedora, and Arch cloud images, provisioned via cloud-init on first boot.
- Persistent disk VMs for anything else: install once with the guest's own installer, then boot straight to it.
- Ships a few ready-made recipes (XFCE, Docker, dev tools, Tailscale) to run post-boot over ssh or bake into a cloud-init seed. `stoat recipe new` scaffolds your own.
- QEMU processes are tracked by pidfile, not supervised: `stoat` can exit and the VM keeps running.
- A TUI for interactive use, and a scriptable CLI (`ls`, `up`, `down`, `ssh`, `apply`, `rm`, `recipe`, `guest`, `screenshot`, `logs`, `doctor`) covering the same operations for scripts and automation. Every command takes `--json` for one object per line, errors included.
[Get started](docs/getting-started/installation.md) ·
[Documentation](docs/README.md) ·
[CLI reference](docs/reference/cli.md) ·
[Releases](https://github.com/NovusEdge/stoat/releases)

## What it looks like
![Stoat lists two running VMs and one stopped VM.](assets/tui-list.png)

The list screen, with one running VM, one stopped, and one whose `vm.toml` failed to parse (captured with `tmux capture-pane` against a throwaway `STOAT_HOME`):
## Install and check

```
███████╗████████╗ ██████╗ █████╗ ████████╗
██╔════╝╚══██╔══╝██╔═══██╗██╔══██╗╚══██╔══╝
███████╗ ██║ ██║ ██║███████║ ██║
╚════██║ ██║ ██║ ██║██╔══██║ ██║
███████║ ██║ ╚██████╔╝██║ ██║ ██║
╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝

╭────────────────────────────────────────────────────────────────╮
│ │
│ ❯ ○ alpine-live live 2048M 2c - │
│ │
│ ○ ubuntu-dev cloud 4096M 4c - │
│ │
│ ✗ old-vm broken: toml: line 1: expected '.' or │
│ '=', but got 'i' instead │
│ │
╰────────────────────────────────────────────────────────────────╯

↵ start/stop • →/l details • s ssh • p provision • / search • n new • r recipes • d delete • q quit • ? help
```

The same VMs from `stoat ls`:

```
$ stoat ls
NAME MODE STATE CPUS RAM SSH
alpine-live live stopped 2 2048 2200
ubuntu-dev cloud stopped 4 4096 2201
old-vm - broken - - - toml: line 1: expected '.' or, but got 'i' instead
```

## Requirements

- Linux with KVM: `/dev/kvm` readable and writable by your user (member of the `kvm` group).
- `qemu-system-x86_64` and `qemu-img`. GTK+OpenGL support is needed only for the one VM that opens a window (`-display gtk,gl=on`, a disk VM mid-install); with no graphical session on the host, that console falls back to VNC and the VM still installs.
- `ssh`.
- `xorriso` (package `libisoburn`), only if you use a cloud-init image (Ubuntu/Debian/Fedora/Arch). Not needed for Alpine live or disk VMs.
- Go 1.26 to build from source (the version pinned in `go.mod`).

## Install
Stoat runs on Linux with KVM, QEMU, OpenSSH, and `xorriso` for cloud images.
Building from source needs Go 1.26 or newer, Git, and Just.

```sh
git clone https://github.com/NovusEdge/stoat.git
cd stoat
just setup
```

Builds stoat, installs it to `~/.local/bin` (override with `$PREFIX`), offers to add that directory to your `PATH`, and reports which of `qemu-system-x86_64`, `qemu-img`, `ssh`, `xorriso`, and `/dev/kvm` still need attention, with the exact install command for your distro.

For a non-interactive install (CI, scripts):
`just setup` builds Stoat, installs it under `~/.local/bin`, checks host
dependencies, and offers to add that directory to `PATH`. Open a new shell if
your shell configuration changed, then run:

```sh
just build && just install # or: make build && make install
stoat doctor
```

`install` puts the binary at `$PREFIX/stoat` (default `~/.local/bin/stoat`); make sure that's on your `PATH`.
See [installation](docs/getting-started/installation.md) for host packages,
release archives, Nix, and KVM permission details.

Release tarballs and the Nix flake are covered in [docs/getting-started/installation.md](docs/getting-started/installation.md).
## Start a Debian VM

## Quick start

```
stoat
```sh
stoat pull debian-13
stoat create dev --image debian-13 --ram 2048 --cpus 2
stoat up dev
stoat wait dev --until reachable --timeout 2m
stoat exec dev -- cat /etc/debian_version
stoat down dev
```

1. Press `n` for a new VM.
2. Pick an image and press SPACE to download it (not enter: enter tries to create the VM and tells you to download first).
3. Press ENTER to create the VM.
4. Back on the list, press ENTER to start it.
5. If you checked any recipes, stoat waits for sshd and then offers to run
them: `test1 is up, run xfce now? y/N`. Press `y`, or decline and press
`p` whenever you like.
6. Press `s` to ssh in.

See [docs/getting-started/first-vm.md](docs/getting-started/first-vm.md) for the walkthrough with screenshots and troubleshooting.

## Commands

| Command | Purpose |
|---|---|
| `stoat` | launch the interactive TUI |
| `stoat ls` | list VMs, one line per VM |
| `stoat up <name>` | start a VM |
| `stoat down <name>` | stop a VM (graceful) |
| `stoat ssh <name>` | ssh into a VM, replacing this process |
| `stoat apply <name> [--dry-run]` | run the VM's recipes, streaming output to stdout |
| `stoat rm <name> [-y]` | delete a VM; refuses while running, confirms unless `-y` |
| `stoat screenshot <name> [-o path]` | write the VM's screen to a PNG |
| `stoat recipe list` | list installed recipes and where they live |
| `stoat recipe new <name> [--os alpine] [--backend cloudinit]` | scaffold a recipe in the recipes directory |
| `stoat guest ls` | list the guest OS definitions stoat knows |
| `stoat guest show <name>` | print one guest definition |
| `stoat logs [-n N]` | tail the stoat log (default 50 lines) |
| `stoat doctor` | check host prerequisites |
| `stoat version` | print the stoat version |

Global flags: `-q`/`--quiet`/`--no-interactive` suppress progress chatter (results and errors still print). Exit codes: 0 success, 1 runtime failure, 2 usage error. Full reference: [docs/reference/cli.md](docs/reference/cli.md); TUI keys: [docs/reference/tui.md](docs/reference/tui.md).

## How it works

Every VM is one of three modes:

- **live**: diskless, boots straight from an Alpine ISO, discards all state on stop. Provisioned by an **apkovl** overlay built fresh at every start.
- **disk**: a qcow2 that survives restarts. You run the guest's own installer once, then flip it to "installed". Provisioned by pushing recipes over **ssh**.
- **cloud**: a CoW overlay over a shared Ubuntu/Debian/Fedora/Arch base image. Provisioned by a **cloud-init** seed baked in once, at first boot only.
`create` writes the VM without starting it. `wait --until reachable` confirms
that SSH answers. `down` retains the disk; `stoat rm dev` deletes a stopped VM
after confirmation. [Your first VM](docs/getting-started/first-vm.md) covers
recipes, display access, and expected results.

Each VM is a directory under `~/.stoat` (override with `$STOAT_HOME`) holding a hand-editable `vm.toml` plus whatever state its mode keeps, nothing else is shared between VMs. See [docs/concepts/modes-and-backends.md](docs/concepts/modes-and-backends.md) and [docs/concepts/data-root.md](docs/concepts/data-root.md).
## Use the TUI

Recipes (the scripts and cloud-init fragments that install XFCE, Docker, etc.) are covered in [docs/recipes/overview.md](docs/recipes/overview.md), including how to write your own. A recipe script runs with a prelude of guest-neutral verbs (`stoat_pkg_install`, `stoat_svc_enable`, and the rest) rendered from the guest's own definition, so one script can serve several distros.
Run `stoat` with no subcommand. Press `n` to choose an image, **Space** to
download it, and **Enter** to create it. Press **Enter** on the list to
start or stop the selected VM, `s` to open SSH, `p` to apply recipes, and `r`
to edit the recipes directory in `$EDITOR`. Press `?` for help and `q` to quit.

Guest OS facts live in one TOML file per OS, bundled in the binary and overridable from `~/.stoat/guests/`. Adding an OS is a file, not a code change: see [docs/reference/guest.md](docs/reference/guest.md).
For repository-managed VMs, declare `[vms.<key>]` in `stoat.toml` and commit
the file. `stoat up`, `status`, and `down` then reconcile and operate on the
declared VMs. See the [project file reference](docs/reference/project-file.md).

## Status
## Recipes, MCP, and scripts

Pre-1.0 and single-user: stoat assumes it's the only thing managing its `~/.stoat`, and offers no sandboxing beyond what QEMU/KVM already give a guest. The Alpine live path is the most exercised mode; the cloud-init backends and disk-mode installs are newer. `vm.toml`'s shape and the CLI's flags may still change before 1.0.
- [Recipe overview](docs/recipes/overview.md) explains targeting, parameters,
secrets, outputs, health checks, and persistence.
- [Sharing recipes](docs/recipes/sharing.md) covers remote refs, two scopes,
lock, sync, search, update, and removal.
- [MCP reference](docs/reference/mcp.md) covers client setup, transports,
project tools, and agent access levels.
- [JSON output](docs/reference/json.md) documents the CLI contract and DTOs.
- [Troubleshooting](docs/troubleshooting.md) covers boot, SSH, display, and
provisioning failures.

## Contributing
## See it running

`CONTRIBUTING.md` covers setup, the branch and PR flow, commit grammar, the
gates CI runs, and the test tiers. New recipes go in `~/.stoat/recipes/`;
the bundled set is closed.
The recording shows Stoat's TUI list and detail screens followed by an XFCE
desktop in a QEMU window.

## License
[Watch the MP4](assets/demo.mp4) · [Capture details](assets/README.md)

AGPL-3.0-or-later. Copyright (c) 2026 Aliasgar Khimani (NovusEdge).
Stoat stores VM configuration and state under `~/.stoat`; set `STOAT_HOME` to
use another data root. See [modes and backends](docs/concepts/modes-and-backends.md)
and [the data root](docs/concepts/data-root.md) for storage behavior.

Free to clone, run, and modify, including by automated agents and harnesses. Any distributed or network-hosted derivative must publish its source under the same license, so stoat cannot be forked into a proprietary product.
Stoat is pre-1.0. [Contributing](CONTRIBUTING.md) covers development setup
and checks. It is licensed under [AGPL-3.0-or-later](LICENSE).
96 changes: 96 additions & 0 deletions assets/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Screenshots and recording

These captures show Stoat built from commit `7f985bc` on Linux x86-64 with
QEMU 11.1.1. They were captured on 2026-09-06 in Europe/Helsinki time.
The guests use a separate `STOAT_HOME` and cached Alpine 3.24.1, Debian 13,
and Ubuntu 24.04 images.

| File | Content |
|---|---|
| [tui-list.png](tui-list.png) | Main TUI in Konsole; two running VMs and one stopped VM |
| [tui-details.png](tui-details.png) | Alpine VM details and the completed XFCE apply log |
| [tui-create.png](tui-create.png) | New-VM form with a cached Alpine image |
| [qemu-xfce.png](qemu-xfce.png) | Actual QEMU window running Alpine XFCE |
| [alpine-xfce.png](alpine-xfce.png) | Earlier guest display captured through QMP with `stoat screenshot` |
| [demo.mp4](demo.mp4) | 23-second H.264 screen recording, 1280 × 1000, 15 fps, no audio |
| [demo.gif](demo.gif) | Animated copy of the screen recording, 10 fps |

## Capture method

The TUI images and video capture pixels from a Konsole window on KDE.
Konsole used its default color scheme, a 12-point monospace font, and an
opaque background. `NO_COLOR` was unset in the capture process. The main
image uses a 1280 × 768 window; the details and form use 1280 × 1000 so
the full content and key hints fit.

FFmpeg captured the Konsole and QEMU Xwayland windows with `x11grab` and
`-draw_mouse 0`. The TUI hides its text cursor in the list and details views.
The guest terminal's text cursor was hidden for its demonstration command.
The QEMU clip has padding above and below to match the terminal clip's height.
The PNG files are window captures; `alpine-xfce.png` is a direct QMP capture.

The video spends five seconds on the VM list, nine on VM details, and nine
on QEMU. The guest terminal opens during the QEMU segment and prints the
Alpine and kernel versions. The details show `xfce (applied unknown)`: the
recipe completed, and the bundled XFCE recipe has no health check.

## Reproduce the guests

Install the [host prerequisites](../docs/getting-started/installation.md),
then create a separate data root:

```sh
export STOAT_HOME="$(mktemp -d "${TMPDIR:-/tmp}/stoat-docs.XXXXXX")"
stoat pull alpine-standard
stoat pull debian-13
stoat pull ubuntu-24.04

stoat create alpine-desktop --image alpine-standard --ram 2048 --cpus 2 --recipes xfce
stoat create debian-dev --image debian-13 --ram 2048 --cpus 2
stoat create ubuntu-lab --image ubuntu-24.04 --ram 2048 --cpus 2

STOAT_GRAPHICAL=1 stoat up alpine-desktop --json
STOAT_GRAPHICAL=0 stoat up debian-dev
stoat wait debian-dev --until reachable --timeout 2m
stoat exec debian-dev -- cat /etc/debian_version
```

Alpine opens a QEMU window and applies XFCE on its first start. Ubuntu stays
stopped. For the window capture, `xrandr` was installed in Alpine and the
display was set to 1280 × 768. The guest terminal ran `cat /etc/alpine-release`
and `uname -sr`. Future image and package versions can change the output.

Open Stoat in Konsole, select `alpine-desktop`, and press `l` for details.
Press **Escape** to return, then `n` to capture the creation form.
**Escape** closes the form without creating another VM.

```sh
env -u NO_COLOR TERM=xterm-256color COLORTERM=truecolor \
QT_QPA_PLATFORM=xcb konsole -e stoat
```

On an X11/Xwayland window, find its ID with `xprop -root _NET_CLIENT_LIST`
and verify its title with `xprop -id <id> _NET_WM_NAME`. Record that window:

```sh
ffmpeg -f x11grab -draw_mouse 0 -framerate 15 -window_id "$WINDOW_ID" \
-i "$DISPLAY" -c:v libx264 -crf 20 -pix_fmt yuv420p \
-movflags +faststart capture.mp4
```

Set `WINDOW_ID` to the chosen window's numeric ID. Use a new output path
for each capture. Pure Wayland windows require a compositor-supported recorder.

## Stop the guests

```sh
stoat down alpine-desktop
stoat wait alpine-desktop --until stopped --timeout 30s
stoat down debian-dev
stoat wait debian-dev --until stopped --timeout 30s
```

Stopping Alpine discards its live desktop. See the
[live recipe restart limitation](../docs/troubleshooting.md#a-live-vm-lost-everything-after-a-reboot)
before reusing a live VM. Debian's disk and the VM definitions remain in
the capture data root.
Binary file added assets/alpine-xfce.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/demo.mp4
Binary file not shown.
Binary file added assets/qemu-xfce.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tui-create.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tui-details.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/tui-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 17 additions & 11 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# stoat
# stoat documentation

stoat is a terminal UI for running local QEMU virtual machines. It is
Alpine-first, and it manages each VM through a small `vm.toml` file instead of
Expand All @@ -10,15 +10,21 @@ stoat needs no libvirt, no background daemon, and no database, just a single
Go binary that shells out to `qemu-system-x86_64`, `qemu-img`, and `ssh`, and
a data root of plain files under `~/.stoat` (or `$STOAT_HOME`).

New here? Start with [Installation](getting-started/installation.md), then
[Your first VM](getting-started/first-vm.md).
Use the [installation guide](getting-started/installation.md) to prepare the
host, then follow [Your first VM](getting-started/first-vm.md). Alpine live is
the shortest path to a working shell. Alpine disk VMs install unattended and
retain their disk; cloud VMs use a first-boot cloud-init seed.

## Status
For repeatable development environments, commit a `stoat.toml` and its
`stoat.lock`, then use the [project workflow](guides/project-workflow.md).
For an agent client, use the [MCP workflow](guides/mcp-workflow.md).

stoat is pre-1.0 and single-user: it assumes it's the only thing managing its
data root, and there's no remote access or multi-tenant story. The Alpine
live-boot path (`apkovl` provisioning, no install to disk) is the most
exercised mode, so that's what to reach for first. Cloud-image provisioning
(`cloud-init` seeds) and installed disk VMs work but have seen less mileage.
Expect rough edges, and check [Troubleshooting](troubleshooting.md) if
something doesn't behave as documented.
The [concepts](SUMMARY.md#concepts) explain storage, access, modes,
networking, project shares, and provisioning. The [reference](SUMMARY.md)
contains the CLI, JSON, TUI, guest, project, and recipe details.

stoat is pre-1.0 and single-user: it assumes it is the only process managing
its data root and does not manage remote VMs or provide multi-tenant
isolation. Cloud-image and installed-disk paths are implemented, but Alpine
live has received the most exercise. Start with
[Troubleshooting](troubleshooting.md) when a command reports an error.
5 changes: 5 additions & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
* [Installation](getting-started/installation.md)
* [Your first VM](getting-started/first-vm.md)

## Workflows

* [Project workflow](guides/project-workflow.md)
* [MCP workflow](guides/mcp-workflow.md)

## Concepts

* [Modes and backends](concepts/modes-and-backends.md)
Expand Down
Loading
Loading