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
14 changes: 14 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,26 @@ order, when given no VM argument. A bare VM argument resolves against
| [`guest show`](#stoat-guest-show-name) | Print one guest's merged definition | 0, 1 |
| [`logs`](#stoat-logs-name--n-n) | Tail a VM's log, or stoat's own | 0, 1 |
| [`screenshot`](#stoat-screenshot-name--o-path) | Write the VM's screen to a PNG | 0, 1 |
| [`capabilities`](#stoat-capabilities-vm) | Report current agent capabilities | 0, 1 |
| [`doctor`](#stoat-doctor) | Check host prerequisites | 0, 1 |
| [`version`](#stoat-version) | Print the stoat version | 0 |
| [`help`](#stoat-help) | Show the usage message | 0 |

Anything not on this list, a missing VM name, or extra arguments is a **usage error** (exit 2), printed to stderr together with the full usage text.

## stoat capabilities [VM]

Reports the host checks, implemented Stoat surfaces, target access limits, and
unavailable runtime proposals. It reads stored VM metadata only and does not
start or connect to a VM.

Omit the VM name for host and project scope. In a project, a bare name first
resolves against the current stoat.toml and then against a global VM name.
Without --json, Stoat prints a NAME, STATUS, SCOPE table. Add --json for the
standard result envelope with the schema 1 capability report.

MCP enforces agent_access. Direct CLI stoat exec and stoat cp do not enforce it.

## `stoat ls`

Lists every VM directory under the data root, plus any directory whose `vm.toml` failed to parse (shown with a `broken` state and a one-line reason). Broken VMs are real entries, not hidden.
Expand Down
60 changes: 60 additions & 0 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,63 @@ and five MCP tools (`project_status`, `project_up`, `project_down`,
`project_apply`, `project_wait`) alongside `start`, `stop`, `apply_recipes`
and `wait`, which keep their existing inputs and outputs. All additions; the
contract stays 3.

## Capability discovery

stoat capabilities [VM] --json returns a report with schema 1 in the usual
result envelope. The command reads host checks and one VM's stored metadata.
It does not start, connect to, or mutate a VM. Omit VM for host and project
scope; a target adds its directory name, stored mode, and normalized
agent_access values.

The report has these fields:

| Field | Meaning |
|---|---|
| schema | Capability report schema, currently 1. |
| stoat_version | Build version supplied by Stoat. |
| host | os, arch, and project_state (available, absent, or unknown). |
| target | Optional stored VM snapshot with name, mode, and agent_access. |
| access_policy | mcp_agent_access_enforced, cli_agent_access_enforced, and cli_commands (exec, cp). |
| profiles | Implemented runtime profiles and their host or guest requirements. |
| capabilities | Implemented current surfaces and their requirements, limits, and evidence. |
| unavailable | Explicitly unavailable surfaces. |

Each profile or capability has name, status, scope, requirements, limits,
optional reason, and evidence. Status is supported, limited, unsupported, or
unknown. supported means the implementation is available and its required
observations are available. Discovery does not establish VM readiness. limited
carries a limit code; unsupported and unknown carry a reason code. Every list
is [] when empty, never null.

The stable reason codes are not_implemented, host_probe_unavailable,
agent_access_unknown, target_mode_unknown, and project_state_unknown. The
stable limit codes are agent_access_required, target_required, disk_required,
project_file_required, and host_requirement_missing. MCP enforces
agent_access; direct CLI exec and cp do not. A live target limits vm.snapshot
with disk_required. runtime.fork and runtime.continuation always appear under
unavailable with unsupported/not_implemented.

```json
{
"schema": 1,
"stoat_version": "dev",
"host": {"os": "linux", "arch": "amd64", "project_state": "absent"},
"access_policy": {
"mcp_agent_access_enforced": true,
"cli_agent_access_enforced": false,
"cli_commands": ["exec", "cp"]
},
"profiles": [],
"capabilities": [],
"unavailable": [
{"name": "runtime.fork", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.fork", "result": "not_implemented"}]},
{"name": "runtime.continuation", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.continuation", "result": "not_implemented"}]}
]
}
```

The example abbreviates profiles and capabilities; a real report always
contains the implemented entries. The proposal evidence identifies the report
entry, while its status and reason state that the runtime surface is
unavailable.
6 changes: 6 additions & 0 deletions docs/reference/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ that run guest code are marked by their required level.

### Host and recipe tools

The read-only capabilities tool reads host checks and stored VM metadata. Its
optional vm argument selects one VM; omit it for host and project scope. It
does not start or connect to a VM, and it reports MCP access limits plus
unavailable fork and continuation proposals. Discovery does not mutate a VM.

| Tool | Purpose |
|---|---|
| `list_vms`, `vm_status` | List VMs or inspect one VM, including recipe state and health. |
Expand All @@ -100,6 +105,7 @@ that run guest code are marked by their required level.
| `add_recipe`, `update_recipe`, `remove_recipe` | Add, repin, or remove remote recipes. `add_recipe` accepts curated index names only; it refuses Git URLs. `remove_recipe` has no force option. |
| `list_guests`, `guest_info` | Inspect loaded guest definitions and their package/service commands. |
| `doctor`, `logs` | Check host prerequisites or tail a VM's console/apply log. |
| `capabilities` | Read host checks and optional stored VM metadata; report current capabilities and limits. |
| `create`, `start`, `stop`, `destroy`, `update`, `clone` | Manage VM definitions and lifecycle. `destroy` deletes the VM and its disk. |
| `snapshot`, `restore`, `forward`, `wait`, `prune` | Manage disk snapshots, port forwards, state waits, and stale files. `prune` is dry-run unless `apply=true`. |
| `project_status`, `project_up`, `project_down`, `project_apply`, `project_wait` | Inspect or operate on every VM declared by the server working directory's `stoat.toml`, in declaration order. A failure stops the run and later VMs are marked skipped. |
Expand Down
172 changes: 172 additions & 0 deletions internal/capabilities/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package capabilities

import (
"runtime"

"github.com/novusedge/stoat/internal/core"
)

// Build evaluates the supplied host and metadata observations without I/O.
func Build(in Input) Report {
report := Report{
Schema: 1,
StoatVersion: in.Version,
Host: Host{OS: runtime.GOOS, Arch: runtime.GOARCH, ProjectState: in.ProjectState},
AccessPolicy: AccessPolicy{
MCPAgentAccessEnforced: true,
CLIAgentAccessEnforced: false,
CLICommands: []string{"exec", "cp"},
},
Profiles: []Profile{},
Capabilities: []Capability{},
Unavailable: []Capability{},
}
if in.Target != nil {
target := *in.Target
report.Target = &target
}

report.Profiles = append(report.Profiles, qemuProfile(in.HostChecks))
report.Profiles = append(report.Profiles,
Profile{Name: "recipe-sh", Status: StatusSupported, Scope: ScopeRuntime,
Requirements: []Requirement{}, Limits: []Limit{}, Evidence: []Evidence{implementationEvidence("recipe.runtime.sh")}},
Profile{Name: "recipe-python3", Status: StatusSupported, Scope: ScopeRuntime,
Requirements: []Requirement{requirement("guest_runtime", "python3", "")}, Limits: []Limit{}, Evidence: []Evidence{implementationEvidence("recipe.runtime.python3")}},
)

add := func(entry Capability) { report.Capabilities = append(report.Capabilities, entry) }
add(currentEntry("vm.lifecycle", StatusSupported, ScopeVM, targetRequirement(), nil, nil))

snapshot := currentEntry("vm.snapshot", StatusSupported, ScopeVM, targetRequirement(), nil, nil)
if in.Target != nil {
snapshot.Evidence = append(snapshot.Evidence, configEvidence("vm.mode", in.Target.Mode))
switch in.Target.Mode {
case "live":
snapshot.Status = StatusLimited
snapshot.Limits = append(snapshot.Limits, Limit{Code: LimitDiskRequired})
case "disk", "cloud":
case "":
snapshot.Status = StatusUnknown
snapshot.Reason = &Reason{Code: ReasonTargetModeUnknown}
default:
snapshot.Status = StatusUnknown
snapshot.Reason = &Reason{Code: ReasonTargetModeUnknown}
}
}
add(snapshot)

add(currentEntry("recipes", StatusSupported, ScopeVM, targetRequirement(), nil, nil))

project := currentEntry("project.operations", StatusSupported, ScopeProject, nil, nil, nil)
project.Evidence = append(project.Evidence, configEvidence("project.scope", in.ProjectState))
switch in.ProjectState {
case "available":
case "absent":
project.Status = StatusLimited
project.Limits = append(project.Limits, Limit{Code: LimitProjectFileRequired})
case "unknown", "":
project.Status = StatusUnknown
project.Reason = &Reason{Code: ReasonProjectStateUnknown}
}
add(project)

for _, name := range []string{"mcp.guest.observe", "mcp.guest.manage", "mcp.guest.exec"} {
need := name[len("mcp.guest."):]
entry := currentEntry(name, StatusSupported, ScopeVM, targetRequirement(), nil, nil)
entry.Requirements = append(entry.Requirements, requirement("mcp_agent_access", "agent_access", need))
if in.Target != nil {
entry.Evidence = append(entry.Evidence, configEvidence("vm.agent_access", in.Target.AgentAccess))
have, ok := accessRank[in.Target.AgentAccess]
want := accessRank[need]
if !ok {
entry.Status = StatusUnknown
entry.Reason = &Reason{Code: ReasonAgentAccessUnknown}
} else if have < want {
entry.Status = StatusLimited
entry.Limits = append(entry.Limits, Limit{Code: LimitAgentAccessRequired, Value: need})
}
}
add(entry)
}

cli := currentEntry("cli.guest.shell", StatusSupported, ScopeCLI, targetRequirement(), nil, nil)
add(cli)
add(currentEntry("host.diagnostics", StatusSupported, ScopeHost, nil, nil, nil))

for _, name := range []string{"runtime.fork", "runtime.continuation"} {
report.Unavailable = append(report.Unavailable, Capability{
Name: name, Status: StatusUnsupported, Scope: ScopeRuntime,
Requirements: []Requirement{}, Limits: []Limit{}, Reason: &Reason{Code: ReasonNotImplemented},
Evidence: []Evidence{{Kind: "implementation", Source: name, Result: ReasonNotImplemented}},
})
}
return report
}

var accessRank = map[string]int{"none": 0, "observe": 1, "manage": 2, "exec": 3}

func requirement(kind, name, value string) Requirement {
return Requirement{Kind: kind, Name: name, Value: value}
}

func targetRequirement() []Requirement {
return []Requirement{{Kind: "target", Name: "vm"}}
}

func implementationEvidence(source string) Evidence {
return Evidence{Kind: "implementation", Source: source, Result: "implemented"}
}

func configEvidence(source, result string) Evidence {
return Evidence{Kind: "config", Source: source, Result: result}
}

func currentEntry(name, status, scope string, requirements []Requirement, limits []Limit, reason *Reason) Capability {
if requirements == nil {
requirements = []Requirement{}
}
if limits == nil {
limits = []Limit{}
}
return Capability{Name: name, Status: status, Scope: scope, Requirements: requirements, Limits: limits, Reason: reason, Evidence: []Evidence{implementationEvidence(name)}}
}

func qemuProfile(checks []core.HostCheck) Profile {
requirements := []Requirement{
requirement("host_tool", "qemu-system-x86_64", ""),
requirement("host_tool", "qemu-img", ""),
requirement("host_device", "/dev/kvm", ""),
}
p := Profile{Name: "qemu-x86_64", Status: StatusUnknown, Scope: ScopeHost, Requirements: requirements, Limits: []Limit{}, Evidence: []Evidence{}}
byName := make(map[string]core.HostCheck, len(checks))
for _, c := range checks {
if _, exists := byName[c.Name]; !exists {
byName[c.Name] = c
}
}
missing := false
for _, req := range requirements {
c, ok := byName[req.Name]
if !ok {
missing = true
continue
}
result := "failed"
if c.OK {
result = "available"
}
p.Evidence = append(p.Evidence, Evidence{Kind: "host_check", Source: c.Name, Result: result})
}
if missing {
p.Reason = &Reason{Code: ReasonHostProbeUnavailable}
return p
}
p.Status = StatusSupported
for _, req := range requirements {
if c := byName[req.Name]; !c.OK {
p.Status = StatusLimited
p.Limits = append(p.Limits, Limit{Code: LimitHostRequirementMissing, Value: req.Name})
}
}
return p
}
Loading
Loading