The --json contract
On this page
Every Skrog command that reports state can emit machine-readable JSON with
--json. This is the contract that tools build on — the
VS Code extension, CI scripts,
fleet health checks — so it is governed by three rules:
- Additive only. Fields are added, never renamed or removed. A consumer that ignores unknown fields keeps working across Skrog versions.
- Exit codes mean the same thing as the human output —
0ok,1error,2usage,3not installed / not found — and a non-zero exit still emits the JSON when there is something to say (e.g.version --jsonexits 3 with no engine,cli status --jsonexits 3 with tools missing). - Arrays are arrays. An empty list is
[], nevernull. Wherenullis used it is deliberate and documented (config --json→engine).
Shapes are pinned by cmd/skrog/jsonshapes_test.go.
skrog status --json
{
"installed": true,
"distro": "skrog-engine",
"stateDir": "C:\\Users\\me\\AppData\\Local\\Skrog",
"supervisor": "running",
"engine": "running",
"desired": "running",
"profile": "work",
"gpu": { "enabled": true, "probed": true, "visible": true, "specInstalled": true },
"endpoint": {
"pipe": "\\\\.\\pipe\\docker_engine",
"dockerHost": "npipe:////./pipe/docker_engine",
"reason": "default pipe is free"
}
}
supervisor:running|stopped.engine:running|idle|stopped.idleis the engine stopped by the idle timeout — healthy, it wakes on the nextdockercall. Scripts can tell it from broken.desired:running|stopped(whatskrog start/stoplast asked for).profile: omitted when no profile is active.gpu:visibleandspecInstalledare only probed while the engine is running andenabledis true — status never boots a stopped distro (#82).probed: falsemeans they are not authoritative.endpoint: where the engine is answering, and absent unless a supervisor is running — nothing is being served then, so there is no endpoint to name.pipeis the named pipe;dockerHostis the same thing spelled the way the docker CLI wants it, so a consumer settingDOCKER_HOSTdoes not have to convert between the two.reasonsays why that pipe: the default one was free, or something else (usually Docker Desktop) already had it.- It reports what the running supervisor bound, read back from its own record — never recomputed. Docker Desktop can start or stop after Skrog chose, so asking the selector again could name a pipe nothing is serving (#273).
Exit 0 always (an uninstalled machine is installed: false, not an error).
skrog version --json
{ "app": "0.3.0", ... } — the full component picture; see skrog version.
Exits 3 when no engine is installed (still emits JSON).
skrog doctor --json
{
"app": "0.4.0",
"worst": "warn",
"results": [
{
"name": "wsl-version",
"title": "WSL default version",
"status": "ok",
"summary": "default version is 2",
"detail": ["..."],
"remedy": "wsl --set-default-version 2",
"fixed": "set the default WSL version to 2"
}
]
}
An object, not an array: worst saves a consumer from folding the
statuses itself, and app identifies the build that ran the checks. The
results are under results. status is one of ok | skip | warn |
fail; detail, remedy and fixed are omitted when empty, and fixed
records what --fix did on this run.
Exit code is 0 when every check passed or only warned, 1 when one or
more failed, 2 usage — the same three doctor --help states. A warning is
not a failure: doctor warns about things worth knowing that do not stop the
engine working, so gating a fleet script on a non-zero exit would report
healthy machines as broken.
Both of these were documented wrongly here until v0.4.0 (#238) — as an array, and with
1meaning warn. A consumer written against the old text iterated object keys and treated a failing doctor as a usage error.
skrog config --json
{
"settings": { "idle-timeout": "off", "audit": "on", "network.proxy": "", "gpu": "off", "...": "..." },
"engine": { "engine.registry-mirrors": "https://mirror.corp", "...": "..." }
}
settings: every known key with its stored value or default — a stable key set.engine: the engine’sdaemon.jsonkeys.nullwhen no engine is installed;{}when installed with nothing set. The two are different answers.
skrog cli status --json
{
"arch": "amd64",
"binDir": "C:\\...\\Skrog\\bin",
"onPath": true,
"activeDocker": "C:\\...\\Skrog\\bin\\docker.exe",
"tools": [
{ "name": "docker", "version": "29.8.0", "role": "cli", "path": "...", "installed": true, "available": true },
{ "name": "compose", "version": "5.5.1", "role": "plugin", "path": "...", "installed": true, "available": true }
]
}
available: whether the manifest publishes the tool for this arch at all (the docker CLI has no Windows arm64 build —available: falsethere).activeDocker: thedockerthat resolves on PATH; omitted if none.- Exits
3when an available tool is not installed.
skrog snapshot … --json
snapshot list --json→ array (always) of{ "name", "created", "engineVersion", "distro", "sha256", "sizeBytes" }.snapshot save <name> --json→ one such object.snapshot restore <name> --yes --json→{ "restored": "<name>" }.snapshot delete <name> --json→{ "deleted": "<name>" }.
Flags come before the verb: skrog snapshot --json list.
skrog profile … --json
profile --json(list) →{ "active": "work", "profiles": [ { "name": "work", "active": true }, … ] }(activeomitted when none;profilesalways an array).profile show <name> --json→ the profile as the same document its YAML holds:distro,data-dir,engine-version,idle-timeout,autostart,engine,hooks,integrations(unset fields omitted).
skrog audit tail
The audit log is already JSON lines — one audit.Event per line:
{"time":"2026-09-09T16:46:39.226Z","action":"image-pull","method":"POST","path":"/v1.44/images/create","image":"nvidia/cuda:12.4.1-base-ubuntu22.04","status":200,"ms":1840}
Fields: time, action, method, path, image, name, container,
status, ms, error (optional ones omitted when empty). Records are derived
from the request line only, never the body.
- Default output: JSON lines (one parse per line; friendly to
tail -f). audit tail --json: the same records as one JSON array, for a single parse.[]when the log does not exist yet.
skrog install --json
The resulting install manifest: { "distro", "dataDir", "rootfsUrl", "rootfsSha256", "engineVersion", "installedAt", "wslVersion" }.
skrog remote … --json
remote --json(list) →{ "current": "desktop", "remotes": [ { "name": "desktop", "host": "tcp://my-desktop.corp:2376", "added": "…", "certNotAfter": "2028-09-09T16:00:00Z", "dir": "C:\\...\\remotes\\desktop", "current": true } ] }currentis"local"when docker is on theskrogcontext, a remote’s name when onskrog-<name>, and""when docker is on some other context entirely.remotesis always an array.remote test <name> --json→{ "name", "serverVersion", "ms" }. Exits1when the remote does not answer,3when no such remote.
skrog audit trace --json -- <cmd> [args]
Runs the command, then reports what it did to the engine from the audit records appended while it ran:
{
"command": ["act", "-j", "build"],
"exitCode": 0,
"ms": 41200,
"events": 9,
"actions": { "image-pull": 2, "container-create": 3, "container-start": 3, "exec-start": 1 },
"images": ["catthehacker/ubuntu:act-latest", "node:20"],
"containers": ["act-build-1a2b", "db"],
"note": "audit log rotated during the run; the summary covers the current file"
}
exitCodeis the traced command’s own; the process exits with it too, soaudit trace -- make testfails exactly whenmake testdoes.images/containersare the distinct ones touched — always arrays.noteis omitted unless something qualifies the summary (rotation mid-run, or no records written at all).- Attribution is by log position (appended after the command started), so concurrent docker use during the run is included.
- Requires
auditto be on; otherwise exits1with the recipe. --rawinstead prints the matching records as JSON lines.
skrog healthcheck --json
A readiness probe for runner warm-ups and orchestrators:
{ "installed": true, "supervisor": "running", "engine": "idle", "ready": true,
"reason": "engine idle; wakes on the next docker command" }
readyis the verdict the exit code carries:0ready,1not ready,3not installed.reasonis never omitted.- Ready means a docker command would succeed now: the supervisor is serving the
pipe and the engine is
runningoridle(idle wakes on demand). --wait <duration>keeps probing until ready or the deadline; nothing is started by the probe itself — pair it withskrog start.
skrog logs --json
One object per line, the same envelope for every source so a log shipper needs one pipeline:
{"source":"dockerd","line":"time=\"2026-09-09T16:44:18Z\" level=info msg=\"Daemon has completed initialization\""}
{"source":"supervisor","line":"time=... level=INFO msg=\"engine socket is up\" distro=skrog-engine"}
{"source":"audit","line":"{\"time\":\"...\",\"action\":\"image-pull\",...}"}
--source supervisor|dockerd|audit(default supervisor);-n <lines>(default 200,0= all);--followstreams new lines and survives the 5 MB rotation.lineis the raw record; parse it further if you want dockerd’s logfmt fields or the audit event’s JSON.- Exits
3for--source dockerdwith no engine installed.
skrog prewarm --json <images.txt>
Pulls a pinned image list ahead of need (runner warm-up, golden-image bake, post-start hook), through whatever docker currently targets:
{
"file": "images.txt",
"concurrency": 3,
"pulled": 2,
"failed": 1,
"ms": 8420,
"images": [
{ "ref": "alpine:3.20", "ok": true, "ms": 1210 },
{ "ref": "node:20@sha256:…", "ok": true, "ms": 8390 },
{ "ref": "ghcr.io/x/missing:1", "ok": false, "ms": 640, "error": "manifest unknown" }
]
}
imagesis in list order and always an array;erroris omitted on success.- Exit
0only whenfailedis0; a failed pull never stops the others. - The list file: one reference per line,
#comments and blank lines ignored, duplicates dropped. Digest pins encouraged.
skrog runner check --json
One verdict on whether an unattended host will bring the engine back after a reboot (see auto-logon-runner.md):
{
"ready": false,
"findings": [
{ "name": "autologon", "status": "ok", "summary": "auto-logon is configured" },
{ "name": "autologon-account", "status": "ok", "summary": "auto-logon uses this account" },
{ "name": "autologon-password", "status": "warn",
"summary": "the auto-logon password is stored in clear text in the registry (Winlogon\\DefaultPassword)",
"remedy": "use Sysinternals Autologon, which stores it as an LSA secret, then delete the DefaultPassword registry value (docs/auto-logon-runner.md §3)." },
{ "name": "autostart", "status": "fail", "summary": "no logon autostart; the session will start but the supervisor will not",
"remedy": "run `skrog autostart enable` as the auto-logon account (needs skrogw.exe beside skrog.exe)." },
{ "name": "supervisor", "status": "ok", "summary": "supervisor is running" },
{ "name": "power", "status": "warn",
"summary": "the machine sleeps on mains power (sleeps after 1h0m0s), which suspends a job mid-run",
"remedy": "powercfg /change standby-timeout-ac 0 && powercfg /change hibernate-timeout-ac 0" },
{ "name": "engine", "status": "ok", "summary": "engine is running" }
]
}
readyis the exit code’s verdict:0ready (warnings allowed),1not ready,3not installed.findingsis always an array;statusisok|warn|fail;remedyis omitted whenok.powerreads the active scheme’s AC timeouts only: a laptop on battery having short DC timeouts is correct, not a misconfiguration. It warns and never fails — many runners are desktops that will never sleep, and a check that fails a healthy host is one people learn to ignore. A reading that could not be taken warns as unknown rather than passing.- Read-only and unelevated. The auto-logon account is compared, never printed, and the password value is probed for existence only.
skrog reset --to <snapshot> --json
The runner’s clean slate — snapshot restore with the interactive guards
implied (no --yes, no running-container check):
{ "snapshot": "golden", "engineVersion": "29.7.2", "ms": 6840 }
msis the whole cycle — verify the archive, unregister, import, engine back — the number a clean-slate budget is measured against.engineVersionis the snapshot’s recorded engine, omitted when unknown.- Exit
3when the snapshot does not exist or nothing is installed;1when the restore failed (the engine is brought back best-effort either way).
skrog prune --json
Reclaims disk on whatever docker currently targets:
{
"reclaimedBytes": 1234000000,
"failed": 0,
"steps": [
{ "name": "containers", "reclaimedBytes": 100000000 },
{ "name": "images", "reclaimedBytes": 1134000000 },
{ "name": "build-cache", "reclaimedBytes": 0, "error": "..." }
]
}
stepsis in plan order (containers → images → volumes → build-cache) and always an array;erroris omitted on success. A failed step never stops the others.- Exit
0only whenfailedis0. --allremoves every unused image (default: dangling only);--until 168hkeeps anything newer;--build-cacheand--volumeswiden the sweep (volumes hold data, so off by default).
skrog compact --json
Shrinks the engine’s virtual disk (fstrim + CompactVirtualDisk):
{
"distro": "skrog-engine",
"path": "C:\\Users\\me\\AppData\\Local\\Skrog\\distro\\ext4.vhdx",
"trimmed": true,
"offeredBytes": 1078939029504,
"beforeBytes": 15032385536,
"afterBytes": 9663676416,
"reclaimedBytes": 5368709120,
"waitedSeconds": 66.4,
"restarted": true,
"dryRun": false
}
reclaimedBytesis the difference in the file’s size on disk and the only honest measure of what happened.offeredBytesis whatfstrimprinted: the free extent of the whole virtual disk, not space reclaimed. Named “offered” so nothing mistakes it for a result; omitted when--no-trimwas used.heldis present when other distros are keeping WSL from releasing the disk, and pairs with exit code 11. Under--dry-runit appears without an error: the dry run reports that a real run would refuse, and names who.waitedSecondsis how long WSL took to let go (about a minute after the last distro stops).- Exit codes:
0ok,1error,2usage,3not installed,11the disk is held.
skrog relocate --json
Moves the engine’s data directory to another drive:
{
"distro": "skrog-engine",
"from": "C:\Users\me\AppData\Local\Skrog\distro",
"to": "D:\skrog",
"movedBytes": 439422976,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"needBytes": 1098907648,
"freeBytes": 181070462976,
"restarted": true,
"dryRun": false
}
movedBytesandsha256describe the transfer archive: the engine is exported, checksummed, and only then is the old distro unregistered, so the archive is the recovery point if the import fails.needBytesis the peak requirement on the target, roughly twice the current disk, because the archive and the imported disk exist at the same time. Checked before anything is touched; short space pairs with exit code 12 and changes nothing.- Under
--dry-runthe payload isstepsplusneedBytes/freeBytes— what it would do and whether it would fit. archiveKeptappears with--keep-archive, or when the archive could not be removed, and names the file to delete by hand.- Exit codes:
0ok,1error,2usage,3not installed,12not enough space.
skrog policy show --json / skrog policy test --json
Admission control (#120). show reports the rules in effect:
{
"path": "C:\Users\me\AppData\Local\Skrog\policy.yaml",
"exists": true,
"active": true,
"enforced": true,
"rules": { "denyPrivileged": true, "allowBindSources": ["C:\work"] }
}
test is the verdict on one container-create body, for gating a script:
{
"denied": true,
"rule": "allow-bind-sources",
"reason": "policy does not allow bind mounts from C:/secrets (allowed: C:\work)"
}
rulenames the key that refused, so a script can branch on it without parsing prose;reasonis the same sentence the user would see.active/enforcedare false when the file is absent or sets no rules — both mean every request is allowed.- Exit codes:
0allowed,1error,2usage,3not installed,13the rules deny it. 13 is separate so “refused” is distinguishable from “the command went wrong”.
skrog engine list --json
What this build can install, what is installed, and where a rollback goes:
{
"installed": "29.7.2-4",
"previous": "29.7.2-3",
"available": [
{ "ref": "29.7.2-4", "version": "29.7.2", "default": true, "published": true }
]
}
refis the revisioned label and the unit an upgrade moves between;versionis only the dockerd version, and two revisions can share one.publishedisfalsefor a manifest entry with no checksum yet — a placeholder that cannot be installed.installedandpreviousare omitted when unknown;availableis always an array.
skrog engine upgrade --json / skrog engine rollback --json
{
"from": "29.7.2-3",
"to": "29.7.2-4",
"replaced": ["dockerd", "containerd", "runc", "..."],
"engineVersion": "29.7.2",
"rolledBack": false,
"dryRun": false
}
engineVersionis what the new dockerd reports about itself — evidence the swap took, not an assumption that it did.rolledBack: truewith a non-zero exit is the interesting case: the upgrade failed and the previous engine was restored, so the engine is up.replacedandengineVersionare absent on a dry run.
skrog wsl-config show|apply --json
The WSL2 VM’’s sizing, from the global ~/.wslconfig (#148):
{
"path": "C:\\Users\\me\\.wslconfig",
"exists": true,
"effective": { "memory": "4GB", "processors": "2", "autoMemoryReclaim": "gradual" },
"desired": { "memory": "4GB", "processors": "2", "autoMemoryReclaim": "gradual" },
"pending": [ { "key": "memory", "old": "8GB", "new": "4GB", "added": false } ],
"applied": false
}
effectiveis what the file says now;desiredis what Skrog’s own settings ask for;pendingis the difference — so a converge script can tell “already right” from “would change something” without parsing prose.pendingis omitted when there is nothing to do, which is the signal that a repeatedapply --yesis a no-op.appliedistrueonly when this invocation wrote the file.apply --jsonrequires--yes: there is no way to ask a question in JSON, so it exits2rather than appearing to hang.
skrog status --stats --json
--stats adds a stats object; the rest of the shape above is unchanged,
because it is a readiness-probe contract. Statistics are opt-in for two
reasons: collecting them costs WSL calls a probe should not pay, and they are
only meaningful when the engine is already running.
{
"installed": true, "distro": "skrog-engine", "engine": "running",
"stats": {
"probed": true,
"supervisor": {
"fresh": true, "readingAgeSeconds": 0.2,
"uptimeSeconds": 3600, "engineUptimeSeconds": 3600,
"engineStarts": 1, "idleStops": 2,
"lastIdleStopAt": "2026-09-10T14:00:00Z", "lastWakeAt": "2026-09-10T14:31:00Z"
},
"bridge": {
"connections": 512, "bytesToEngine": 3281, "bytesToClient": 12883,
"activeConns": 0, "transport": "vsock"
},
"engine": {
"version": "29.7.2", "containers": 1, "containersRunning": 1,
"containersPaused": 0, "containersStopped": 0,
"images": 9, "volumes": 2,
"imagesBytes": 12191537, "volumesBytes": 0,
"buildCacheBytes": 0, "reclaimableBytes": 490
},
"disk": {
"path": "C:\\Users\\me\\AppData\\Local\\Skrog\\distro\\ext4.vhdx",
"sizeOnDiskBytes": 415236096, "guestUsedBytes": 305328128,
"reclaimableBytes": 109907968, "hostFreeBytes": 428330541056
},
"vm": {
"cpus": 20, "memTotalBytes": 33481715712,
"memAvailableBytes": 31422464000, "swapTotalBytes": 8589934592,
"configuredMemory": "4GB", "configuredProcessors": "2"
}
}
}
probedsays whether the engine was up. Statistics never start it (#82), soprobed: falseomitsengine,diskandvmentirely rather than reporting zeroes — “no data” and “zero containers” must not look alike.supervisorandbridgecome from a file the supervisor flushes, so they are present even with the engine down.freshandreadingAgeSecondssay whether they still describe the present: a supervisor that died leaves its last numbers behind, andfresh: falseis how a reader knows not to trust them as current.bridge.transportisvsock(fast path, ~0.6 ms per connection),fallback(the socat relay, ~165 ms — the vsock agent is unreachable),socat(that path pinned bySKROG_NO_VSOCK) orunknown. This is the field that explains a slowdockerwith a perfectly healthy engine.engine.reclaimableBytesis whatskrog prunecould free;disk.reclaimableBytesis size-on-disk minus guest-used, roughly whatskrog compactcould return — an estimate, since compaction works in blocks.vm.configured*is what~/.wslconfigasks for, omitted when it asks for nothing. Comparing it withmemTotalBytesandcpuscatches the trapskrog wsl-configcloses: a limit recorded and never applied.errorsnames anything that could not be read, so a partial reading is honest rather than silently short.
The rule for new commands
Anything that gains state reporting must gain --json in the same change and
be added here; its shape goes in cmd/skrog/jsonshapes.go with a test.