A lazydocker-style terminal UI for managing a fleet of Steam devkits (Steam Machine, Steam Deck, ...) — pairing, deploying builds, checking status, and tailing logs from one keyboard-driven panel, instead of Valve's single-target PySDL2/imgui GUI.
Recorded with vhs against a canned
backend (just demo, see scripts/demo/). The UI is the real TUI; the devkits
are not.
brew install kevintcoughlin/lazydeck/lazydeck # or see "Installing a pre-built release"
lazydeck # add devices with `a`, `?` for keysI run a Steam Deck and a Steam Machine devkit side by side, and Valve's
steamos-devkit client only wants to talk to one at a time through a
Python/PySDL2/imgui GUI — no scriptable entrypoint, no fleet view. Clicking
through the same GUI twice for every deploy got old fast.
lazydeck vendors Valve's MIT-licensed devkit_client library (see
python/vendor/, pulled from the actively-maintained
flibitijibibo/steamos-devkit
fork) so the real pairing/SSH/rsync/mDNS protocol logic stays exactly what
Valve's own GUI uses. A thin headless CLI (python/cli.py) drives that
library and prints JSON, and a Go + Bubble Tea TUI (cmd/lazydeck) wraps
the CLI with one panel per configured device.
- Go 1.25+
uv(manages the Python 3.10+ venv/deps for you; first run needs network access to fetch them)justtask runnerssh/rsyncavailable on your machine (standard on macOS)- optional:
golangci-lintandruff(brew install golangci-lint ruff).just lintuses them automatically if present, otherwise falls back togo vet/py_compile.
Releases are built for macOS and Linux (amd64/arm64) via
goreleaser — see
Releases and
CHANGELOG.md. Each
archive bundles the lazydeck binary alongside python/ so you don't need
to clone the repo. You still need uv installed locally; LazyDeck finds the
sibling Python runtime and provisions its locked dependencies into your user
cache on first run.
Install the macOS release with the Homebrew tap:
brew install kevintcoughlin/lazydeck/lazydeckThe formula depends on uv, installs the bundled python/ runtime, and
creates its writable managed environment under Homebrew's var directory on
first run. No separate uv sync step is needed. The formula currently
supports macOS and Linux on amd64 and arm64.
Alternatively, install.sh automates the above (downloads the right
archive for your OS/arch, installs the binary to ~/.local/bin, and copies
python/ to ~/.local/share/lazydeck/python):
curl -fsSL https://raw.githubusercontent.com/kevintcoughlin/lazydeck/main/install.sh | bashThe installer verifies the release checksum, smoke-tests the staged binary and Python bridge, then atomically replaces the existing installation. Pin a release or customize its destination without editing the script:
VERSION=0.2.0 PREFIX="$HOME/.local" ./install.sh
VERSION=0.2.0 INSTALL_DIR="$HOME/bin" LAZYDECK_DATA_DIR="$HOME/lib/lazydeck" ./install.shLinux releases also include amd64/arm64 Debian and RPM packages. They install
the binary, Python runtime, and an architecture-matched pinned uv; the SSH
client (openssh-client / openssh-clients) and rsync remain normal package
dependencies:
sudo apt install ./lazydeck_<version>_linux_amd64.deb # Debian, Ubuntu
sudo dnf install ./lazydeck_<version>_linux_amd64.rpm # Fedora, RHELA multi-arch container image (built from that Debian package) is published to GitHub Container Registry. It suits the stdio MCP server or one-off CLI commands. Use a Linux Docker host for host networking and LAN devkit discovery. The image runs as UID 1000. This setup changes ownership of both host directories (including existing files) to UID 1000 and makes them owner-writable; later host-side access may require sudo if your UID differs. Keep both mounts to persist LazyDeck configuration and SteamOS SSH pairing keys and known hosts. Do not delete existing pairing files:
mkdir -p "$HOME/.config/lazydeck" "$HOME/.config/steamos-devkit" &&
sudo chown -R 1000 "$HOME/.config/lazydeck" "$HOME/.config/steamos-devkit" &&
sudo chmod -R u+rwX "$HOME/.config/lazydeck" "$HOME/.config/steamos-devkit" &&
docker run -i --rm --network host \
-v "$HOME/.config/lazydeck:/home/lazydeck/.config/lazydeck" \
-v "$HOME/.config/steamos-devkit:/home/lazydeck/.config/steamos-devkit" \
ghcr.io/kevintcoughlin/lazydeck:latest mcpNix users can run LazyDeck without a global install, or enter a development shell with the project toolchain:
nix run github:KevinTCoughlin/lazydeck -- version
nix develop github:KevinTCoughlin/lazydeckRelease archives, Debian and RPM packages, checksums, SBOMs, and GitHub build
provenance are generated from the tagged commit. checksums.txt is signed
with Sigstore keyless signing. For the Linux amd64 archive example below, download
the archive, checksums.txt, and checksums.txt.sigstore.json from the same release
into one directory. Run the following there with cosign, GNU sha256sum, and
GitHub CLI (gh) installed. Checksum validation runs only after successful
signature verification; do not install on any failure:
cosign verify-blob checksums.txt \
--bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github.com/KevinTCoughlin/lazydeck/\.github/workflows/release\.yml@' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com &&
sha256sum --ignore-missing -c checksums.txt &&
gh attestation verify lazydeck_*_linux_amd64.tar.gz --repo KevinTCoughlin/lazydeck- Stable —
vX.Y.Zreleases. The default forinstall.sh, Homebrew, the containerlatesttag, andlazydeck version --check(which reports whether a newer stable release exists; lazydeck never checks on its own). - Release candidates —
vX.Y.Z-rc.NGitHub prereleases, installed only when pinned:VERSION=0.3.0-rc.1 ./install.sh. - Nightly — a rolling prerelease rebuilt from
mainwhenever it changes:curl -fsSL https://raw.githubusercontent.com/kevintcoughlin/lazydeck/main/install.sh | VERSION=nightly bash.
git clone <this repo> && cd lazydeck
mise install # optional: installs the versions pinned in mise.toml
just sync # installs exactly the dependencies in python/uv.lock
just build # go build -o lazydeck ./cmd/lazydeckEdit ~/.config/lazydeck/devices.toml (created for you on first run) to
list your devkits. This path is always ~/.config/lazydeck — or
$XDG_CONFIG_HOME/lazydeck if that's set — on every OS, including macOS,
rather than Go's OS-native os.UserConfigDir() default (e.g.
~/Library/Application Support on macOS), so it matches what's documented
here. If an earlier version of lazydeck already wrote a config to that
OS-native location, it's moved into ~/.config/lazydeck automatically the
next time you launch lazydeck (a one-line notice is printed to stderr when
this happens).
# Optional root-level settings; they must precede the first [[device]] table.
refresh_interval_seconds = 30
webhooks = ["https://discord.com/api/webhooks/...", "https://hooks.slack.com/services/..."]
[[device]]
name = "steam-machine"
machine = "192.168.1.50" # hostname, IP, or mDNS service name
login = "deck" # optional; auto-detected if omitted
[[device]]
name = "steam-deck"
machine = "steamdeck.local"Periodic background status refresh is off by default; omit
refresh_interval_seconds to refresh only on startup and with the s key.
webhooks posts a message to each listed URL when a deploy or logs-sync job
finishes, success or failure. Discord and Slack incoming webhook URLs are
detected automatically and formatted natively; anything else gets a generic
JSON body, e.g. for a custom IRC bridge. Omit webhooks to disable
notifications (the default). This only covers jobs run through lazydeck serve's API, which is what the engine integrations use — interactive TUI
deploys go around the job manager and aren't notified.
A team with a lab of devkits can keep one device list everyone reads,
instead of each engineer maintaining a copy. The registry is an ordinary
TOML file of [[device]] tables (name, machine, optional login; no
other keys) kept anywhere readable: a network share, a team git repository
everyone has checked out, or a path your config management provisions.
# /mnt/qa-lab/lazydeck/devices.toml (the shared registry)
[[device]]
name = "lab-deck-01"
machine = "10.20.0.11"
[[device]]
name = "lab-machine-01"
machine = "10.20.0.21"
login = "deck"Point each engineer's own devices.toml at it, as a root-level setting:
registry = "/mnt/qa-lab/lazydeck/devices.toml" # relative paths resolve next to this file
registry_mode = "merge" # default; or "replace"
[[device]] # still allowed: your desk Deck, or a local override
name = "lab-deck-01" # same name as a shared entry, so this one wins
machine = "10.20.0.99"or set LAZYDECK_REGISTRY (and optionally LAZYDECK_REGISTRY_MODE) in the
environment, or pass lazydeck serve --registry PATH. Precedence:
- Which registry:
serve --registry, thenLAZYDECK_REGISTRY, thenregistryindevices.toml. - Which mode:
LAZYDECK_REGISTRY_MODE, thenregistry_mode, thenmerge. merge: shared devices in registry order, then your local-only devices. A local device with the same name as a shared one replaces it entirely (all fields, not field by field).replace: shared devices only; local[[device]]entries are ignored. Use this for a CI host or a teamservethat should see exactly the registry.- Everything else (
refresh_interval_seconds,webhooks) always comes from your localdevices.toml.
The registry is read-only to lazydeck: nothing ever writes to it, so
there is no locking protocol to trust over NFS or SMB. Change it the way you
change any shared file, ideally a reviewed commit in the team repo. The TUI's
add-device wizard (a) saves to your local devices.toml only, and refuses
in replace mode, where a local entry would be ignored. If you edit the
registry by hand on a share, write it atomically (save to a temporary file,
then rename) so nobody reads half a file.
A configured registry that is missing (an unmounted share, a missing
checkout) or malformed (bad TOML, an unknown key, a device without a name
or machine, or a duplicate name) stops lazydeck with an error that names
the file. It never falls back silently to a partial device list. The TUI's
activity log and serve's startup banner say which registry and mode are in
use. serve reads the registry once at startup, so restart it after the
registry changes.
Don't know the Deck's IP yet? Find it via mDNS/Bonjour (works once the Deck is on the same Wi-Fi and Developer Mode pairing is enabled):
just cli discover --timeout 5or press f inside the running TUI.
Then run:
just run # builds (if needed) and launches the TUIThe main screen uses separate Devices, Detail, and Activity panels. They render side-by-side in wide terminals and stack vertically in narrow terminals; long fleets are paginated around the current cursor.
The Godot and Unity integrations locate lazydeck serve through its
session-scoped, permission-restricted connection file at
$XDG_RUNTIME_DIR/lazydeck/serve.json. On Debian installations, enable the
optional systemd user service after configuring at least one device:
systemctl --user enable --now lazydeck-serve.service
systemctl --user status lazydeck-serve.serviceIt's not enabled at package-install time, since it needs your devkit
configuration and local SSH trust state first. Stop it with
systemctl --user disable --now lazydeck-serve.service; the interactive TUI
does not require it. Archive, Homebrew, and Nix users can copy
packaging/systemd/user/lazydeck-serve.service and replace its ExecStart
path with their installed lazydeck binary.
LazyDeck deploys and registers games with Steam, but does not remotely launch
or stop them — the SteamOS devkit protocol has no launch/stop primitive, so
start and stop a deployed title from the device's Steam UI.
/v1/capabilities reports both operations as unavailable; see
the launch policy for background.
Optionally edit ~/.config/lazydeck/config.yml (created for you on first
run, commented out) to bind extra keys to arbitrary shell commands run
against the selected device(s) — lazygit-style, without forking lazydeck.
This file composes with devices.toml; it does not replace it.
customCommands:
- key: "p"
name: "ping device"
command: "ping -c 3 {{.Machine}}"
- key: "u"
name: "uptime"
command: "ssh {{.Login}}@{{.Machine}} uptime"Commands run via sh -c and may reference {{.Name}}, {{.Machine}}, and
{{.Login}} from the targeted device. LazyDeck passes those values as
positional shell arguments rather than source text, so device fields cannot
inject additional shell commands. Like d/l/x, a custom command runs against
every multi-selected device (space) if any are selected, otherwise just
the one under the cursor. Keys that collide with a built-in binding (see
the table below) are ignored so custom commands can never shadow lazydeck's
own behavior; the custom binding also shows up in the ? help screen.
From the repository root, with a paired Deck configured in
~/.config/lazydeck/devices.toml, record a 100×30 terminal session with:
mkdir -p recordings && asciinema rec --overwrite --cols 100 --rows 30 \
--title "lazydeck Steam Deck demo" --command "just run" \
recordings/lazydeck-steam-deck.castUse this non-destructive sequence (wait for each refresh to finish):
s— refresh the configured device status.?, then any key — show and close the keybinding help.g— list deployed games.f— run LAN discovery and show the result in the log.q— quit and let asciinema save the recording.
Before sharing, inspect the cast for hostnames, IP addresses, usernames, game
IDs, paths, or error text. Use a sanitized name in devices.toml, do not
press d, x, or enter during the demo, and remove the local cast if it
contains private output. Recordings under recordings/ are ignored by git.
just # list all recipes
just test # Go + complete Python unit test suites
just lint # Go, Ruff, and shell lint
just check # CI-equivalent race/vet/test/lock checks
just snapshot # GoReleaser check plus local release snapshot
just cli status --machine 192.168.1.50 # call the headless python CLI directly
just clean # remove the built binary and __pycache__ dirsContainerfile is a pinned Linux dev/test environment, not a runtime service
image — LazyDeck is an interactive, host-network terminal app, so there's no
"lazydeck server" to ship as a container.
just container-test| Key | Action |
|---|---|
↑/k |
previous device |
↓/j |
next device |
| mouse | click a device to select it, scroll wheel to move the cursor |
/ |
fuzzy-filter the device list by name/machine (esc clears) |
space |
toggle multi-select (batches d/l/x across the selection) |
s |
refresh status for all devices |
r |
register/pair the selected device |
d |
deploy — prompts for gameid, then local build directory |
l |
sync-logs — prompts for gameid, then local directory to save |
x |
delete a previously deployed title — prompts for gameid, then a y/n confirmation (no undo) |
g |
list games currently deployed on the selected device |
f |
find devkits on the LAN via mDNS/Bonjour (~4s scan, logs only) |
a |
add-device wizard — discover, pick, persist to devices.toml, register |
enter |
open a real interactive ssh shell on the selected device |
? |
toggle the full keybinding help screen |
esc |
cancel an in-progress prompt / wizard |
q |
quit |
Selecting one or more devices with space before pressing d/l/x runs
that operation across every selected device at once — the lazydocker-style
"batch operation on the fleet" workflow.
internal/client.Client.run shells out to:
uv run --no-dev --project python python cli.py <subcommand> --machine <host> [...]cli.py imports the vendored devkit_client package and calls the same
functions Valve's GUI calls (register, steamos_get_status, list_games,
new_or_ensure_game, sync_logs, delete_title), each wrapped to emit a
single JSON envelope ({"ok": true, "data": ...} or
{"ok": false, "error": ..., "error_kind": ...}) that the Go side parses.
┌─────────────────────────┐ ┌──────────────────────────────┐
│ Go TUI (cmd/lazydeck) │ │ Steam Deck / Steam Machine │
│ Bubble Tea + lipgloss │ │ (SteamOS, Developer Mode) │
│ │ │ │
│ internal/tui ────────► │ │ steamos-devkit-client │
│ internal/client ─┐ │ │ (paired via HTTP, port 32000) │
└────────────────────┼─────┘ └───────────────┬────────────────┘
│ `uv run python cli.py <cmd>` │
▼ │
┌─────────────────────────┐ │
│ python/cli.py │ │
│ (headless JSON wrapper) │ │
│ │ │
│ python/vendor/ │ HTTP (pair) │
│ devkit_client ──────────┼──────────────────┤
│ (Valve/Collabora, MIT) │ SSH (paramiko) │
│ │──────────────────┤
│ │ rsync (subproc) │
│ │──────────────────┤
│ │ mDNS/Bonjour │
│ │◄─────────────────┘
└─────────────────────────┘ (_steamos-devkit._tcp.local.)
The Go side never speaks HTTP/SSH/rsync/mDNS itself — it only shells out to
cli.py, which is a thin argparse wrapper around the same vendored library
Valve's own GUI uses. This keeps the actual pairing/deploy protocol logic
in one well-tested place instead of being reimplemented in Go.
The SteamOS pairing protocol does not authenticate an SSH host key out of band.
LazyDeck records first-seen keys in a dedicated steamos-devkit known_hosts
file, separate from your normal OpenSSH database. Unknown keys are enrolled on
first use. Changed keys warn by default so re-imaged devkits remain usable; set
LAZYDECK_SSH_STRICT=1 to reject changed keys. Verify changes out of band and
pair only on a trusted LAN. See SECURITY.md.
lazydeck serve (see issue #13)
exposes the same devkit operations above through a versioned, loopback
HTTP+SSE API, so engine editors can drive them without shelling out to
lazydeck or reimplementing the SteamOS Devkit protocol. Browse the
rendered API docs or read
api/openapi.yaml directly for the contract.
Three editor integrations are built on that API, all covering the same core workflow without leaving the editor: discover, pair, and inspect devkits, deploy a build, and sync logs.
- A Godot 4 editor plugin (Godot 4.3+):
integrations/godot. - A Unity Editor package (Unity 2023.1 or newer, including Unity 6):
integrations/unity. Also offers a batch-mode CLI (-batchmode -quit -executeMethod LazyDeck.Editor.Cli.LazyDeckCli...) for driving build/deploy/log-sync from CI without opening the Editor UI. - An Unreal Engine editor plugin (Unreal 5.x, C++ project):
integrations/unreal. It drives Unreal's cook/package step via UATBuildCookRunfor Linux/Win64, and was written without an Unreal toolchain to compile against — see its README before relying on it.
Run lazydeck serve, then enable the plugin or add the package. Each
directory's README covers current scope, engine-specific build behavior,
and (for Godot/Unity) a runnable example project.
lazydeck mcp exposes the same /v1 API as a third client, this time for
LLM agents (Claude Desktop, VS Code Copilot, etc.) over the Model Context
Protocol, using the official
modelcontextprotocol/go-sdk.
It discovers or auto-starts lazydeck serve exactly like the Godot/Unity
integrations. Read-only tools (list devices, discover, status, games, job
status) are always available; tools that change device or job state
(deploy, pair, sync logs, cancel, launch/stop) are opt-in via
--allow-mutations, since an agent calling those is a different trust
model than a human clicking a button in an editor.
Try prompts like "what Steam devkits do I have configured?", "check the
status of both devices", or "discover Steam devkits on my LAN". See
docs/mcp.md for setup and the mutation-enabled configuration.
The editor integrations and lazydeck mcp authenticate with the per-launch
session token in serve's connection file. A CI job usually needs something
else: a credential that survives serve restarts, works for a build user
that can't read your 0600 connection file, and grants only what the job
needs. Create a scoped API key for it:
lazydeck api-key create --name ci-build --scopes read,deploy,logs:sync --expires-in 2160h
lazydeck api-key list
lazydeck api-key revoke <id> # takes effect on a running serve immediatelyThe key prints once, on stdout; store it in your CI secret store. LazyDeck
keeps only its SHA-256 digest (~/.config/lazydeck/api-keys.json, mode
0600) and compares in constant time. Scopes are read, discover,
pair, deploy, logs:sync, and jobs:cancel; a key may cancel only jobs
it started. Run lazydeck serve --port 47600 (any free port) so the job can
find the server without the connection file, then send
Authorization: Bearer $LAZYDECK_API_KEY.
Limit a key to particular devices with --devices:
lazydeck api-key create --name qa-bench-3 --scopes read,deploy --devices lab-deck-01,lab-deck-02Such a key gets 403 forbidden on every other device's routes (including
names that don't exist, so it can't probe the registry), sees only its devices
in GET /v1/devices, and gets 404 for other devices' jobs. Device names
aren't checked when the key is created, because the registry can change.
By default serve listens on loopback only, so the CI runner or teammate
must be on the same machine. To let other machines on a trusted network
(a lab VLAN, or a VPN such as WireGuard or Tailscale) reach one shared
serve, opt in with --listen:
lazydeck api-key create --name ci-runner --scopes read,deploy,logs:sync
lazydeck serve --listen 0.0.0.0:47600 --registry /mnt/qa-lab/lazydeck/devices.toml--listenadds a second listener. The loopback listener, its connection file, and the editor integrations are unchanged.- The
--listenlistener accepts API keys only. The session token, which can do everything, is refused there even if someone has it, so every network request is scoped and attributed in the audit log. serverefuses to start with--listenuntil the key store holds at least one active (unrevoked, unexpired) key.- The host must be an IP address (or empty, as in
:47600, for every interface), andserveprints a warning to stderr when it isn't loopback. - It is plain HTTP. Keys, build paths, and job output cross the network
unencrypted. lazydeck doesn't terminate TLS itself. If you need TLS, keep
serveon loopback and put a reverse proxy such as Caddy or nginx in front. Point the proxy at a loopback--listenaddress such as--listen 127.0.0.1:47601so proxied requests also get the API-key-only listener. Behind a proxy, the audit log'sremote_addris the proxy's. - The build
directoryin a deploy request is a path on the machine runningserve. Remote CI must put the build somewhere that machine can read, such as a shared volume, before it calls deploy.
serve appends one JSON line per mutating request (discover, pair, deploy,
log sync, cancel), scope denial, rejected API key, and finished job to
$XDG_STATE_HOME/lazydeck/audit.log (default
~/.local/state/lazydeck/audit.log, override with LAZYDECK_AUDIT_LOG).
Each line records the time, the actor (key id and name, or the local user
behind the session token), the action, device, game, build directory, job,
and result. It never records tokens, keys, digests, launch arguments, or
webhook URLs. LazyDeck only ever appends to it; for tamper resistance, make
it append-only at the filesystem level (sudo chattr +a) or ship it to a
log collector.
- "could not locate a complete Python runtime" — set
LAZYDECK_PYTHON_DIRto point at thepython/directory (a pre-built release archive bundles it as a sibling of the binary; a dev checkout resolves it automatically). - "uv is required" — install
uv, or pointLAZYDECK_UVat an executable. Debian and RPM packages include their own copy automatically. - "No interpreter found for Python >=3.10" — uv can't find a Python and
isn't allowed to download one. Distro-packaged uv (Fedora's, for example)
sets
python-downloads = "manual", so installpython3from the distro or runuv python install 3.12once. Homebrew, the.deb/.rpmpackages, and uv from astral.sh download a managed Python automatically. uv runfails with a lock/package error — from a checkout runuv sync --frozen --project python; release layouts are provisioned from their bundled lockfile automatically.- Device shows "offline / unpaired" after
s— pressrto (re-)register your workstation's SSH key with it first; devices must be paired via the same Developer Mode pairing flow the official GUI uses. f/a(mDNS discover) finds nothing — confirm the Deck/Steam Machine is on the same Wi-Fi network/subnet as your Mac (mDNS doesn't cross routed subnets or most VPNs), and that Developer Mode + pairing are enabled on the device. A bare USB-C cable to the Deck does not expose a network interface on retail SteamOS — you need Wi-Fi or a USB-C-to-Ethernet adapter (see Valve's own devkit docs).- A device row turns yellow/orange — that's an
auth-failedorinvalid-inputerror (seeerror_kindin the CLI's JSON, surfaced in the TUI's status color); red meansunreachableor an unexpected script error. Check the log pane at the bottom of the TUI for the full message. ssh(viaenter) fails immediately — the resolved key lives at the path reported byconnection-info; make sure it wasn't deleted or regenerated outside of lazydeck/the official GUI.
python/vendor/devkit_client is Valve/Collabora's code, MIT-licensed — see
python/vendor/LICENSE-steamos-devkit. Its bundled python-zeroconf copy is
LGPL-2.1 and includes the complete license at
python/vendor/devkit_client/zeroconf/COPYING. Go and packaged uv attribution
is recorded in THIRD_PARTY_GO.md and NOTICE.
