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.
I 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)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.
Tagged releases (v*) are built for macOS and Linux (amd64/arm64) via
goreleaser — see
Releases. 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 packages. They install the
binary, Python runtime, and an architecture-matched pinned uv; openssh-client
and rsync remain normal package dependencies:
sudo apt install ./lazydeck_0.2.0_linux_amd64.debNix 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 packages, checksums, SBOMs, and GitHub build provenance are generated from the tagged commit.
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:
# 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.
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 --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-only
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 doesn't drive Unreal's cook/package step yet, 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.
- "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 packages include their own copy 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.