Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
975222a
test(recipes): pin remote recipe foundations
NovusEdge Sep 5, 2026
26cbc56
feat(gitx): add a git subprocess wrapper
NovusEdge Sep 5, 2026
07444c1
test(recipes): avoid staging filename assumptions
NovusEdge Sep 5, 2026
c2fb122
feat(recipes): add the recipe lockfile
NovusEdge Sep 5, 2026
f31e128
feat(recipes): resolve project and global scope
NovusEdge Sep 5, 2026
35dfb6d
feat(recipes): search roots in shadow order
NovusEdge Sep 5, 2026
42f2184
feat(recipes): add the curated recipe index
NovusEdge Sep 5, 2026
211fb40
test(recipes): cover index scope and lock boundaries
NovusEdge Sep 5, 2026
1bc1656
fix(recipes): close chunk one review findings
NovusEdge Sep 5, 2026
ea6c445
test(recipes): preserve refresh after cleanup warning
NovusEdge Sep 5, 2026
d489525
fix(recipes): keep published index after cleanup
NovusEdge Sep 5, 2026
b31ec79
test(recipes): cover managed index listings
NovusEdge Sep 5, 2026
6c08dea
fix(config): hide index workspaces from VM lists
NovusEdge Sep 5, 2026
7325e04
test(remote-recipes): cover chunk 2 boundaries
NovusEdge Sep 5, 2026
10758ab
feat(recipes): validate remote recipe trees
NovusEdge Sep 5, 2026
a6bc9c4
feat(recipes): add recipes from git sources
NovusEdge Sep 5, 2026
867d7cd
feat(recipes): resolve and sync recipe locks
NovusEdge Sep 5, 2026
acf7a46
feat(recipes): update and remove remote recipes
NovusEdge Sep 5, 2026
e939ee4
feat(core): lazily sync project recipes
NovusEdge Sep 5, 2026
f63c4d5
test(remote): harden recipe boundaries
NovusEdge Sep 5, 2026
8861ea4
fix(remote): harden recipe boundaries
NovusEdge Sep 5, 2026
bf6620d
test(remote): cover authenticated recipe refs
NovusEdge Sep 5, 2026
c4e06aa
fix(remote): handle authenticated refs
NovusEdge Sep 5, 2026
92e3990
test(remote): cover chunk three behavior
NovusEdge Sep 5, 2026
c1b4c3c
test(hostcheck): cover optional git check
NovusEdge Sep 5, 2026
758fdae
feat(core): report git as optional host dep
NovusEdge Sep 5, 2026
bd46a0e
feat(cli): add remote recipe subcommands
NovusEdge Sep 5, 2026
9f4f2f8
feat(cli): wire remote recipe commands
NovusEdge Sep 5, 2026
4ac7e82
feat(mcp): add remote recipe tools
NovusEdge Sep 5, 2026
4c36e2e
docs(recipes): document remote recipes
NovusEdge Sep 5, 2026
52ae1ad
test(remote): cover chunk3 review boundaries
NovusEdge Sep 5, 2026
88c1e94
test(cli): cover literal option search data
NovusEdge Sep 5, 2026
93d85d8
fix(remote): close chunk3 boundaries
NovusEdge Sep 5, 2026
e5a9f93
test(remote): cover final recipe review gaps
NovusEdge Sep 5, 2026
e9325d3
fix(remote): close final recipe review gaps
NovusEdge Sep 5, 2026
5b340ee
test(mcp): cover stubborn child cleanup
NovusEdge Sep 5, 2026
06afe41
fix(recipes): surface a scope error from Roots
NovusEdge Sep 5, 2026
7724f06
refactor(recipes): drop the unused Installed helper
NovusEdge Sep 5, 2026
47ed9c9
fix(cli): pass remote recipe lists through nonNil
NovusEdge Sep 5, 2026
f9643a6
fix(gitx): accept an abbreviated commit as a ref
NovusEdge Sep 5, 2026
cb6d54e
fix(recipes): map a rejected recipe tree to invalid_spec
NovusEdge Sep 5, 2026
631eeb9
fix(core): read vm.toml only in RecipeUsers
NovusEdge Sep 5, 2026
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
10 changes: 5 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,11 @@ reviewer rejects a PR that ignores them.

Bundled recipes are `xfce`, `docker`, `devtools`, `tailscale`, and the set
is closed. Write a new recipe in `~/.stoat/recipes/<name>/` from `stoat
recipe new`; see `docs/recipes/writing-your-own.md`. A recipe index for
sharing them is planned and not built yet. Every recipe script
starts with `set -e` and carries the live-vs-disk block that `stoat recipe
new` scaffolds; `internal/recipes/recipes_test.go` checks both for the
bundled set.
recipe new`; see `docs/recipes/writing-your-own.md` and
`docs/recipes/sharing.md` for installing and pinning remote recipes. Every
recipe script starts with `set -e` and carries the live-vs-disk block that
`stoat recipe new` scaffolds; `internal/recipes/recipes_test.go` checks both
for the bundled set.

## Reporting a security issue

Expand Down
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

* [Overview](recipes/overview.md)
* [Writing your own](recipes/writing-your-own.md)
* [Sharing recipes](recipes/sharing.md)

## Troubleshooting

Expand Down
14 changes: 11 additions & 3 deletions docs/design/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ is enforced regardless of what the client does.
| `check_image_id` | Catalog IDs only. An absolute or relative path is rejected: §7.1 #4. |
| `rate_limit` | A token bucket per tool, and a second one shared by every tool. Per-tool alone let a caller burst `capacity` times across each of ~20 tools. The MCP spec makes rate limiting a server `MUST`. |
| `check_flag_free` | Values splatted into argv as positionals (`forward` pairs, `check_recipes` names) must not start with `-`. `forward(pairs=["--clear"])` otherwise reached kong as the clear flag. |
| `check_index_name` | `add_recipe` takes an index name with an optional `@ref`; a URL, path separator in the plain name, dot segment, leading dash, or malformed ref is refused before it reaches the CLI. A valid slash-containing branch ref after `@` is allowed. `update_recipe` and `remove_recipe` take plain names. |

**Never exposed as tools at all** (§7.1): `share` as any parameter, BYO image
paths, `recipe new`, `ssh-command`, and the global (no VM) `logs`. These are
Expand All @@ -129,9 +130,9 @@ may ignore them.

| Class | Tools | `readOnlyHint` | `destructiveHint` |
|---|---|---|---|
| Read-only | `list_vms`, `vm_status`, `list_images`, `list_recipes`, `check_recipes`, `logs`, `doctor`, `plan_recipes` | true | false |
| Mutating | `create`, `start`, `stop`, `apply_recipes`, `update`, `clone`, `snapshot`, `forward`, `wait` | false | false |
| Destructive | `destroy`, `prune`, `restore` | false | true |
| Read-only | `list_vms`, `vm_status`, `list_images`, `list_recipes`, `check_recipes`, `logs`, `search_recipes`, `doctor`, `plan_recipes` | true | false |
| Mutating | `create`, `start`, `stop`, `apply_recipes`, `update`, `add_recipe`, `update_recipe`, `clone`, `snapshot`, `forward`, `wait` | false | false |
| Destructive | `destroy`, `prune`, `remove_recipe`, `restore` | false | true |
| Execution | `exec`, `copy_to`, `copy_from` | false | true, `openWorldHint` true |

`plan_recipes` is `apply --dry-run`. It exists so an agent can read what an
Expand All @@ -146,6 +147,13 @@ and only another snapshot taken later undoes that.
`allow_exec` exactly as `exec` does. A VM created with `--allow-exec=false`
refuses both.

`search_recipes` and `add_recipe` use the curated index. `add_recipe` accepts an
index name and an optional tag or branch ref, including slash-containing branch
refs. It never accepts a repository URL. `update_recipe` and `remove_recipe`
address an existing remote pin by plain name, including one originally added
from a URL. `remove_recipe` has no `force` argument and therefore refuses while
a VM still lists the recipe.

Every schema sets `additionalProperties: false`, so an unexpected parameter is
rejected rather than silently ignored (OWASP MCP guidance).

Expand Down
120 changes: 120 additions & 0 deletions docs/recipes/sharing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Sharing recipes

Remote recipes come from a Git repository with a `recipe.toml` at its root.
Stoat validates the manifest, pins the resolved commit in `stoat.lock`, and
keeps the checkout in `.stoat/recipes/` for a project or `~/.stoat/recipes/`
for the global scope. Git must be installed on the host.

## Add and search

Search the configured index by name or description:

```sh
stoat recipe search my-tools
```

Add an index entry by name. An index name does not prompt for confirmation:

```sh
stoat recipe add my-tools
```

Add directly from a repository URL when the source is not in the index. A TTY
shows the manifest name, target OSes, requirements, and parameters before it
asks for confirmation. `-y` skips that prompt:

```sh
stoat recipe add https://github.com/example/stoat-my-tools@main -y
```

The default index is configured as
`https://github.com/novusedge/stoat-recipes`, but that repository currently
returns 404 and is not operational. Set `STOAT_INDEX` to a reachable Git
repository containing `index.toml` until a published index is available:

```sh
export STOAT_INDEX=/path/to/stoat-recipes
stoat recipe search my-tools
```

Stoat does not create or publish that repository. Index refreshes are cached
for 24 hours; `--refresh` forces a new fetch.

List installed recipes and their scope and short commit pin:

```sh
stoat recipe list
```

Search reads the configured index. `update` addresses an existing remote pin
by its plain name and does not search the index again. This also applies to a
recipe added from a URL:

```sh
stoat recipe update my-tools
```

## Project and global scopes

If the current directory contains `stoat.toml`, recipe commands use project
scope. The declaration lives in its `[recipes]` table:

```toml
[recipes]
my-tools = "v1.2"
other-tools = { source = "https://github.com/example/stoat-other-tools", ref = "main" }
```

Project scope writes `./stoat.lock` and caches checkouts under
`./.stoat/recipes/`. Stoat adds `.stoat/` to `.gitignore` when the directory
is a Git checkout. Commit both `stoat.toml` and `stoat.lock` so another
checkout can reproduce the same recipe commits.

Without `stoat.toml`, commands use the global lock at `~/.stoat/stoat.lock`
and cache at `~/.stoat/recipes/`. Pass `--global` to force global scope from a
project directory. Stoat does not search parent directories for a project
file.

## Lock, sync, update, and remove

Resolve every project declaration to a commit without changing the cache:

```sh
stoat recipe lock
```

Populate the cache from the lock, removing project cache entries no longer in
the lock:

```sh
stoat recipe sync
```

Fetch refs again and repin one recipe, or every remote recipe when no name is
given:

```sh
stoat recipe update my-tools
stoat recipe update
```

Remove a remote recipe after checking that no VM uses it:

```sh
stoat recipe rm my-tools -y
```

If an add would replace a bundled, local, or same-scope recipe, it refuses
unless the replacement is intentional:

```sh
stoat recipe add my-tools --global --force
```

For removal, use `--force` only when intentionally removing a recipe listed by
a VM. A recipe checkout with local changes is never overwritten by update or
sync; copy it to a local recipe first.

`apply` in project scope checks that declarations, lock entries, and cache
checkouts agree. A stale declaration reports a repair instruction to run
`stoat recipe lock`; a missing or changed cache is synchronized before apply.
78 changes: 57 additions & 21 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This document is the contract. The human-facing CLI is documented in

```
stoat --json ls
{"v":2,"type":"result","cmd":"ls","ok":true,"data":{"vms":[...]}}
{"v":3,"type":"result","cmd":"ls","ok":true,"data":{"vms":[...]}}
```

## The consumer contract, in one page
Expand Down Expand Up @@ -106,6 +106,8 @@ bump the contract version. Do not write code that requires them.
| `invalid_spec` | the request itself is malformed |
| `image_not_downloaded` | the image exists in the catalog but not on disk |
| `recipe_not_applicable` | a named recipe cannot run on this VM |
| `in_use` | a recipe is still listed by one or more VMs |
| `git_required` | a recipe operation needs Git on `PATH` |
| `not_running` | the operation needs a running VM |
| `already_running` | the operation needs a stopped VM |
| `no_disk` | the VM has no qcow2 (a live VM has none) |
Expand All @@ -131,6 +133,7 @@ bump the contract version. Do not write code that requires them.
| `canceled` | the context was cancelled |
| `usage` | a bad flag, a missing argument, an unknown subcommand |
| `confirmation_required` | a destructive command was run without `-y` |
| `lock_out_of_date` | a project declaration is not pinned in `stoat.lock` |
| `internal` | anything unanticipated; the escape hatch |

**Codes are only ever added.** Never renamed, never repurposed, never removed.
Expand Down Expand Up @@ -202,7 +205,7 @@ Snapshot {"tag":"clean","vm_state":true,"size_display":"203 MiB",
"created_display":"2026-08-04 12:00:00"}

Check {"name":"qemu-img","ok":false,"detail":"not found",
"fix":["sudo","pacman","-S","qemu-img"]}
"fix":["sudo","pacman","-S","qemu-img"],"optional":false}

PruneItem {"class":"orphaned_image","path":"/home/u/.stoat/isos/old.iso"}

Expand All @@ -219,6 +222,21 @@ RecipeParam {"name":"channel","type":"enum","required":false,
RecipeOutput {"name":"socket","help":"path of the socket"}
RecipeHealth {"check":"docker info","timeout":"30s"}

RecipeEntry {"name":"tailscale","description":"join a tailnet on boot",
"scope":"global","source":"https://github.com/x/stoat-tailscale",
"ref":"v1.2","commit":"9f3c1e2"}

RecipeRoot {"path":"/home/u/.stoat/recipes","scope":"global"}

RecipeAdded {"name":"tailscale","source":"https://github.com/x/stoat-tailscale",
"ref":"v1.2","commit":"9f3c1e2d4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d",
"scope":"global"}

RecipeRemoved {"name":"tailscale","scope":"global"}

IndexEntry {"name":"tailscale","source":"https://github.com/x/stoat-tailscale",
"description":"join a tailnet on boot","os":["alpine"]}

RecipeIssue {"name":"docker","reason":"docker is not offered to debian/cloudinit"}

ApplyPlan {"name":"xfce","action":"run","reason":"never applied",
Expand All @@ -236,6 +254,14 @@ Guest {"name":"fedora","init":"systemd","shell":"/bin/bash",
"source":"bundled"}
```

`RecipeEntry` has `name`, `description`, `scope`, `source`, `ref`, and
`commit`. `scope` is one of `bundled`, `local`, `global`, or `project`; only
`global` and `project` entries carry `source`, `ref`, and the seven-character
commit prefix. `RecipeRoot` identifies each search root with `path` and
`scope`. `RecipeAdded` uses the same remote pin fields for add, lock, sync, and
update results, with the full resolved commit. `RecipeRemoved` contains only
the name and scope.

`state` is one of `stopped`, `running`, `broken`. `error` appears only on a
broken VM.

Expand Down Expand Up @@ -350,27 +376,23 @@ so a leak fails the build rather than shipping.
| `check-recipes` | `{"applicable":false,"issues":[RecipeIssue,...]}` |
| `guest ls` | `{"guests":[Guest,...]}` |
| `guest show` | `{"guest":Guest}` |
| `recipe list` | `{"dir":"...","recipes":["xfce"]}`, see note below |
| `recipe list` | `{"roots":[RecipeRoot,...],"recipes":[RecipeEntry,...]}` |
| `recipe show` | `{"recipe":RecipeSchema}` |
| `recipe new` | `{"path":"/home/u/.stoat/recipes/foo"}` |
| `recipe new` | `{"path":"/home/u/.stoat/recipes/foo/"}` |
| `recipe add` | `{"name":"tailscale","source":"...","ref":"v1.2","commit":"9f3c1e2d4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d","scope":"global"}` |
| `recipe lock` | `{"recipes":[RecipeAdded,...]}` |
| `recipe sync` | `{"recipes":[RecipeAdded,...]}` |
| `recipe update` | `{"recipes":[RecipeAdded,...]}` |
| `recipe rm` | `{"name":"tailscale","scope":"global"}` |
| `recipe search` | `{"recipes":[IndexEntry,...]}` |
| `screenshot` | `{"vm":"work","path":"/home/u/.stoat/work/screenshots/2026-09-05T140302Z.png","bytes":48213,"width":1280,"height":800}` |
| `logs` (no VM) | `{"lines":[...]}` (stoat's own log) |
| `logs <vm>` | `{"vm":"work","which":"console","lines":[...]}` |
| `doctor` | `{"healthy":false,"checks":[Check,...]}` |
| `version` | `{"version":"1.2.3","contract":2}` |
| `version` | `{"version":"1.2.3","contract":3}` |
| `help` | `{"usage":"..."}` |
| `ssh` | **refused**, see below |

Both `recipe` subcommands report `"cmd":"recipe"`, not `"cmd":"recipe list"`,
and both `guest` subcommands report `"cmd":"guest"`. Distinguish them by which
fields `data` carries.

`recipe list` is "every file in the recipes directory", which is not the same
as "every recipe you can use": it currently includes the `.bak` files the
one-time manifest upgrade left behind, and those are not applicable to any VM.
Use `recipes` (which filters by OS and backend) to find something a VM can
actually run; use `recipe list` only to find a recipe directory to inspect.

`get` returns `{"vm":VMStatus}`: `VMStatus` embeds the VM fields directly;
only the outer get result has the `vm` member. `recipes` remains the compatible
string list, while `recipes_detail` adds stored per-recipe state. `health` is the stored aggregate
Expand All @@ -382,6 +404,15 @@ string list, while `recipes_detail` adds stored per-recipe state. `health` is th
and outputs are named arrays sorted by name. A recipe without a health check
has `health:null`; all list fields are `[]`, never `null`.

All `recipe` subcommands report `"cmd":"recipe"`, not the full subcommand
path, and all `guest` subcommands report `"cmd":"guest"`. Distinguish them by
which fields `data` carries.

`recipe list` reports every valid manifest in shadow order. Each row names its
scope (`bundled`, `local`, `global`, or `project`); only remote `global` and
`project` rows carry source, ref, and the seven-character commit prefix. The
`roots` list gives the search order and the scope label for each root.

Fields worth knowing about:

- **`update.changed`** names the fields that actually changed, in wire naming
Expand Down Expand Up @@ -432,9 +463,9 @@ faking one would break the exactly-one-result guarantee everywhere. Use
--dry-run` emits none: it computes the plan host-side and runs nothing.

```
{"v":2,"type":"progress","cmd":"pull","data":{"id":"alpine-virt","done":41943040,"total":62914560,"percent":66}}
{"v":2,"type":"progress","cmd":"pull","data":{"id":"alpine-virt","done":62914560,"total":62914560,"percent":100}}
{"v":2,"type":"result","cmd":"pull","ok":true,"data":{"id":"alpine-virt","downloaded":true,"verified":true,"checksum_available":true}}
{"v":3,"type":"progress","cmd":"pull","data":{"id":"alpine-virt","done":41943040,"total":62914560,"percent":66}}
{"v":3,"type":"progress","cmd":"pull","data":{"id":"alpine-virt","done":62914560,"total":62914560,"percent":100}}
{"v":3,"type":"result","cmd":"pull","ok":true,"data":{"id":"alpine-virt","downloaded":true,"verified":true,"checksum_available":true}}
```

`progress` fires only when the percentage changes, not per read.
Expand All @@ -443,8 +474,8 @@ faking one would break the exactly-one-result guarantee everywhere. Use
a `stage` event at each recipe boundary:

```
{"v":2,"type":"stage","cmd":"apply","data":{"recipe":"xfce"}}
{"v":2,"type":"log","cmd":"apply","data":{"line":"+ apk add xfce4"}}
{"v":3,"type":"stage","cmd":"apply","data":{"recipe":"xfce"}}
{"v":3,"type":"log","cmd":"apply","data":{"line":"+ apk add xfce4"}}
```

The stage boundaries are real, read out of the markers the provisioner already
Expand All @@ -455,7 +486,7 @@ streaming into "silent until exit".

## Versioning

`"v"` is an integer **contract** version, not the build version. It is `2`.
`"v"` is an integer **contract** version, not the build version. It is `3`.

It bumps only for a removal or a meaning change: a field deleted, a unit
changed, an error code split or repurposed, or `result` ceasing to be last.
Expand Down Expand Up @@ -490,3 +521,8 @@ A consumer may rely on:

A consumer may **not** rely on: field order, the exact text of `message`, the
contents of any `*_display` field, or the absence of fields it does not know.

**v3.** `recipe list` changed shape for remote recipes. `dir` became `roots`,
a list of `{path, scope}` in search order, and `recipes` became a list of
`RecipeEntry` objects rather than names. A consumer that read
`data.recipes[]` as strings reads `data.recipes[].name` instead.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/alecthomas/kong v1.16.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/pelletier/go-toml/v2 v2.4.3
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
Expand All @@ -24,7 +25,6 @@ require (
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
Expand Down
Loading
Loading