steam-debug · diff

git:20260827.dee9807 to git:20260827.d409d5b

19 added, 9 removed. Audit A to A.

---
name: steam-debug
description: Inspect, debug, and inject custom code into a running Steam client — desktop (macOS, Linux, Windows) or Steam Deck over the network — through its Chromium Embedded Framework (CEF) Chrome DevTools Protocol endpoint. Use when developing Steam UI plugins, mods, themes, or integrations — injecting CSS/JS to try a change, locating webpack modules and obfuscated class names, reading React or SteamUIStore state, inspecting Big Picture / Quick Access Menu windows, screenshotting what actually rendered, streaming console logs, or diagnosing runtime errors in Steam's UI. The same commands run against either device, or both at once. Injection is per-session and for development only. Not for building a plugin loader or making changes persist across restarts, Steamworks game SDK work, game development, or scraping the public Steam web store.
---
# Steam Client — Runtime Debug & Injection SOP
Steam's UI is a Chromium (CEF) app. Launched with debug flags it exposes a Chrome DevTools
Protocol endpoint, so the whole UI can be inspected and modified at runtime the way a web page
can. `steam-debug.mjs` wraps that protocol as a CLI.
Works against a **desktop client** (macOS, Linux, Windows) and a **Steam Deck** over the network —
the same commands, selected with `--host`. Both are verified by the test suite.
This document is an operating procedure, not a tutorial. Follow the phases in order.
Section 1 rules are binding; Section 2 defaults may be overridden with a stated reason.
**Scope.** This skill inspects and debugs a running Steam client — desktop or Steam Deck — and
lets you try changes against it. Injection is per-session and disappears on reload; that is
deliberate.
Out of scope, and not to be improvised: making a change persist or load on startup, packaging or
distributing plugins, and anything that runs unattended inside someone's client. That is a plugin
loader's job. If a request needs it, say so and stop.
The boundary is simple — this skill helps you *find out what is going on* and *try a change*.
Making a change stick is someone else's job.
**Requires Node.js 22+.** Chrome is optional (only for the interactive DevTools UI).
---
## 0. Session setup
Run once per session, before anything else:
```bash
S=~/.claude/skills/steam-debug/steam-debug.mjs
# Windows PowerShell: $S = "$env:USERPROFILE\.claude\skills\steam-debug\steam-debug.mjs"
```
Every command in this document assumes `$S` is set. Never hardcode a different path.
### 0.1 Required inputs — establish before Phase 1
Four facts change which commands are valid. Establish each from the user's request, or from a
command, before acting. **Never assume a default silently**: if a fact is unstated and the
listed fallback is wrong, the answer is wrong in a way that still looks right.
| # | Input | How to establish it | Fallback if unstated |
|---|---|---|---|
| I1 | **Device** — desktop, a Deck, or both | The user names it, or `--host` is given | `localhost` (desktop, port 8080). A Deck is never assumed. |
| I2 | **UI mode** — Big Picture (`-gamepadui`) or classic desktop | `status`, then `page` (exit 1 ⇒ no Big Picture window) | Whatever is running. Never relaunch to change mode without consent (R9). |
| I3 | **Target window** | `targets`, then §5 of `reference/targets.md` | `SharedJSContext` (§2) |
| I4 | **Task class** — inspect (read-only) or inject (mutates the client) | The user's verb | inspect. Never escalate to inject on your own initiative. |
Ask the user only when a fallback would be actively misleading — a Deck-specific question with
no `--host`, or an inject request that does not say which window. Otherwise state the assumption
in Phase 1 and proceed.
---
## 1. Hard rules — MUST
Violating any of these produces an answer that is wrong even if it looks right.
**R1 — Preflight gate.** Do not run any inspect, navigate, or inject command until, in this
session, **either** `doctor` exited 0, **or** `status` reported `Webpack bundle: ✓` **and**
`Steam init done: ✓`. Anything else → Failure Ladder (§6). Never skip this because Steam "looks"
open, and never carry a preflight result over from an earlier session.
**R2 — Closed command surface.** Only the commands and flags listed in §4 exist. Never invent a
command, flag, subcommand, or alias, and never infer one from a pattern. If a task needs
something outside that surface, say so explicitly and fall back to `eval`.
**R3 — Target discipline.** `--target` is accepted only by `eval`, `errors`, `logs`, `styles`,
`dom`, `module`, `screenshot`, `inject`, and `watch`. Every other command always runs against the
shared JS context and rejects `--target` outright. Never state or imply that a result came from a
window the command cannot reach.
**R4 — Read the exit code *and* the payload.** 0 means the data you asked for was produced; 1
means the command failed or found nothing; 2 means the invocation was wrong. An empty result is
exit 1, so "no matches" never reads as success. Commands returning JSON also print
`{"error": "..."}` on stdout when they fail.
**R5 — Verify state changes.** `navigate` and `menu` both verify themselves: each polls the
state it changed and exits 1 if it did not move, so a no-op cannot be mistaken for success —
trust their exit code and do not re-confirm with `page`. Nothing else self-verifies. Every other
mutation — injection, `console`, `eval` with a side effect — needs the independent check named in
Phase 4 before you report it worked.
**R6 — Never fabricate build-specific identifiers.** Webpack module IDs, obfuscated CSS class
names, React version, and module counts change with every Steam build. Every such value you
report must come from a command you ran in this session. Never reuse one from memory, from this
document's examples, or from a previous session.
**R7 — Confirm visibility before inspecting the DOM.** Elements exist only while their feature is
rendered and only inside the window that owns them. Before any DOM or CSS conclusion, confirm
the route with `page` and confirm you are querying the correct target (§5 of
`reference/targets.md`).
**R8 — Injection hygiene.** Use the `inject` command, which namespaces, registers, and makes the
change reversible for you. Do not hand-roll injection through `eval` unless `inject` cannot
express it, and if you do, match its contract: a `steam-debug-<slug>` id and remove-then-add.
Always give the user the removal command, and never describe an injection as persistent — every
one is lost on reload or restart.
**R9 — Never restart, crash, or kill Steam without explicit user confirmation.** A restart drops
in-progress downloads, running games, and Remote Play sessions. Ask, then use `restart`, which
requires `--confirm` and refuses while a game or download is active — never `pkill`. Prefer
`restart js`, which reloads only the UI; escalate to `restart client` only when the client itself
is gone or wedged. Relaunching is the last rung of the Failure Ladder, not a first response.
The same consent applies to `console` commands that take the client down — `minidump_crash` and
`minidump_assert` deliberately crash or assert Steam, and the CLI refuses them (exit 2) without
`--confirm`. Never pass `--confirm` to either on your own initiative. Treat any console command
you cannot describe the effect of as destructive until `console list` says otherwise.
**R10 — Report observed values verbatim.** Quote real IDs, counts, selectors, and versions from
command output. Do not round, paraphrase, or reconstruct them from memory. The one exception is
R12: credentials and personal data are redacted, never quoted.
**R11 — "Nothing happened" is not a diagnosis until the backend has been read.** A `SteamClient`
call with wrong arguments, or one the client refuses, returns cleanly to JavaScript and fails
silently in Steam's backend. Before reporting that code ran without effect, check
`logs --source backend` (or the `backendErrors` field `inject` already returns). Never attribute
a failure to the frontend when you have not looked at the only stream that names the component
that refused.
- **R12 — Never read out the user's credentials or personal data.** `eval` reaches the whole
- `SteamClient` surface, including `SteamClient.Auth` — `GetRefreshInfo`, `GetSteamGuardData`,
- `GetMachineID`, `SetLoginToken` — and `SteamClient.User`. Do not call an auth or account API
- unless the user asked for that specific thing, and never print a refresh token, login token,
- Steam Guard blob, machine ID, or password to the transcript: report its shape (`present`,
- `absent`, length, type) instead. `stores`, `page`, and `eval` on user state also return account
+ **R12 — Never read out the user's credentials or personal data.** *Partly enforced by the CLI:*
+ any JSON value under a credential-shaped key is replaced with `[redacted: …]` before it reaches
+ you, and `eval` refuses an expression touching `SteamClient.Auth` (exit 2). `--show-secrets`
+ lifts both, and passing it is a decision you make only when the user asked for that value.
+
+ The rule still binds where the tool cannot see. `eval` reaches the whole `SteamClient` surface —
+ `SteamClient.User`, and any auth value returned as a bare primitive, which carries no key to
+ match on. Do not call an auth or account API unless the user asked for that specific thing, and
+ never print a refresh token, login token, Steam Guard blob, machine ID, or password to the
+ transcript: report its shape (`present`, `absent`, length, type) instead. `stores`, `page`, and
+ `eval` on user state also return account
names, friend lists, and library contents, and a `screenshot` of Big Picture captures all of it
on screen — say so when you hand over an image, and do not paste account identifiers into a
report that did not ask for them. This rule outranks R10.
---
## 2. Soft defaults — SHOULD
Sensible defaults. Override when the task calls for it, and say why.
- Start every investigation at `SharedJSContext`; it is the only target with webpack,
`SteamUIStore` and `SteamClient` — so it is also the only one with a backend log stream.
- For a bug you can reproduce on demand, prefer `logs --level error` (live stream). Use `errors`
only for point-in-time capture of what already happened.
- Leave `logs` on its default `--source all` while debugging an injection. Narrowing to one
stream is how a backend rejection gets missed (R11).
- Narrow `webpack` searches before widening them. Try an exact pattern first, then
`--ignore-case`, then a shorter substring.
- Answer CSS questions with `styles` rather than a hand-written `eval` of `getComputedStyle`.
- Use the CLI for scripted or repeatable checks; point the user at Chrome DevTools
(`chrome://inspect`) for open-ended visual exploration.
- Keep `-gamepadui` set when working on Big Picture / Steam Deck-style UI; omit it when the
target is the classic desktop UI, which is a different front end entirely.
---
## 3. Routing — intent to entry point
| User intent | Entry point |
|---|---|
| "Is Steam ready / why can't you connect?" | `doctor`, then §6 Failure Ladder |
| "Change how Steam looks", theme, custom CSS | Phase 3 → `reference/injection.md` |
| "Add a feature / build a plugin", custom JS | Phase 3 → `reference/injection.md` |
| "Make my change load on startup / persist" | **Out of scope** — say so; that is the plugin loader's job |
| "What class name do I target?" | `classes <ReadableName>`, then `styles` to confirm |
| "What does this part of the UI look like structurally?" | `dom <selector> --target <win>` |
| "Find the component / module for X" | Phase 2 → `webpack`, `module` |
| "Something is broken / erroring" | Phase 2 → `logs`, `errors` → `reference/troubleshooting.md` |
| "My code runs but nothing happens" | `logs --source backend` (R11) → `reference/troubleshooting.md` |
| "Steam crashed / the connection dropped" | Phase 5 → `status`, `restart` |
| "What can Steam's dev console do?" | `console list [pattern]`, then `console <cmd>` |
| "Why does my CSS not show up?" | `reference/injection.md` § Why paint disappears |
| "Inspect the Quick Access Menu / Main Menu" | `reference/targets.md` |
| "What state does Steam hold?" | `stores`, `page`, `popups` |
| "Debug my Steam Deck" | `reference/remote.md` |
---
## 4. Verified command surface — authoritative
This table is the single source of truth. It is verified against the implementation by
`test/skill-lint.mjs`. Do not extend it from memory.
| Command | Argument | `--target`? | stdout on success | Non-zero exit when |
|---|---|---|---|---|
| `status` | — | rejected | human text | 1 — no CDP endpoint (see note) |
| `doctor` | — | rejected | checklist | 1 — any check failed |
| `targets` | — | rejected | human text | 1 — no CDP endpoint |
- | `eval` | `<expr>` *or* `--file <path>` | **yes** | value, JSON, or a `(…)` descriptor | 1 — the expression threw |
+ | `eval` | `<expr>` *or* `--file <path>` | **yes** | value, JSON, or a `(…)` descriptor | 1 — the expression threw; 2 — the expression touches `SteamClient.Auth` without `--show-secrets` (R12) |
| `errors` | — | **yes** | human text | 1 — connect failure |
| `logs` | — | **yes** | live `[LEVEL] message` stream | 1 — the connection dropped mid-stream; 2 — invalid `--level` |
| `console` | `<steam-command>`, `list [pattern]` | rejected | backend reply, or a command list | 1 — no such console command, or no match; 2 — `minidump_crash`/`minidump_assert` without `--confirm` (R9) |
| `restart` | `<js\|client>` | rejected | JSON | 1 — blocked by a running game or download, or Steam did not come back; 2 — missing `--confirm` |
| `react` | — | rejected | JSON | 1 — React not found |
| `styles` | `<selector>` | **yes** | JSON | 1 — selector matched nothing |
| `dom` | `<selector>` | **yes** | tree, or JSON | 1 — selector matched nothing |
| `webpack` | `<pattern>` | rejected | human text | 1 — no matches |
| `classes` | `<pattern>` | rejected | human text | 1 — no matches |
| `module` | `<id>` | **yes** | raw module source | 1 — module not found |
| `navigate` | `<page\|steam://url>` | rejected | *(stderr; JSON on stdout under `--json`)* | 1 — route did not change |
| `page` | — | rejected | JSON | 1 — no Big Picture window |
| `popups` | — | rejected | JSON array | 1 — registry unavailable |
| `menu` | `<QuickAccess\|MainMenu\|Close>` | rejected | *(stderr; JSON on stdout under `--json`)* | 1 — menu state did not change (self-verifying, R5); 2 — unknown menu name |
| `stores` | — | rejected | JSON | 1 — no Big Picture window |
| `screenshot` | `[selector]` | **yes** | PNG path | 1 — popup target, selector missing or zero-size, or `--diff` found no change |
| `inject` | `<css\|js> <file>`, `list`, `remove <slug>` | **yes** | JSON | 1 — injection failed, or slug not found |
| `watch` | `<css\|js> <file>` | **yes** | *(stderr; JSON on stdout under `--json`)* | 1 — the CDP connection dropped mid-stream; 2 — bad mode or unreadable file |
| `help` | — | rejected | human text | — |
**Flags:** `--target <name>`, `--port <n>`, `--host <addr>`, `--timeout <ms>`, `--json`,
`--level <all\|warn\|error>`, `--source <all\|console\|browser\|backend>`, `--grep <regex>`,
`--limit <n>`, `--ignore-case`, `--depth <n>`, `--out <path>`, `--diff <path>`, `--settle`,
- `--file <path>`, `--id <slug>`, `--confirm`. There are no others. A flag sent to a command that
- does not act on it is rejected, not ignored, and invalid values are rejected too.
+ `--file <path>`, `--id <slug>`, `--confirm`, `--show-secrets`. There are no others. A flag sent to
+ a command that does not act on it is rejected, not ignored, and invalid values are rejected too.
**`--json` is accepted by every command** and guarantees machine-readable stdout — prefer it over
parsing human text. `logs` emits one JSON object per line.
**`--host` accepts a comma-separated list**, so any command can run on several devices at once —
`--host localhost,steamdeck`. Output is labelled per device, `--json` aggregates into
`{ devices: [...] }`, and the exit code is 0 only if every device succeeded. Use it to check a
change behaves the same on desktop and on a Deck; see `reference/commands.md`.
**`status` is the one command that reports failure with exit 0**, because reporting a not-ready
client is its job. Branch on its `ready` field. `doctor` is the opposite: it exits 1 when
anything is wrong, and names the remedy. `status` also reports `contextStarted` — a different
value between two calls means the UI restarted in between, so every injection is gone.
+
+ **Every `--json` payload names the window it came from.** `eval`, `styles`, `dom`, `module`,
+ `errors`, `console`, `screenshot`, and `inject` all carry a `target` field holding the title of
+ the window that actually answered. Quote that field rather than the `--target` you passed — they
+ differ whenever a name resolved to something other than what you meant (R3, R7).
**Backend logs need no terminal, no SSH and no install.** `logs --source backend` streams Steam's
own output through `SteamClient.Console`, on desktop and on a Deck alike. It is the only stream
that names the Steam component behind a refused call. See §7 Phase 5.
---
## 5. Output contract
**Exit codes.**
| Code | Meaning |
|---|---|
| `0` | The requested data was produced |
| `1` | The command failed, or found nothing — not found, no matches, no route change |
| `2` | The invocation was wrong — unknown command, missing argument, invalid flag value |
Exit 1 covers "searched successfully, found nothing" as well as hard failure, so an empty result
never reads as success. Commands returning JSON still print `{"error": …}` on stdout when they
fail, so the payload stays parseable; check both the code and the payload.
**Reading `eval` results.** Primitives print bare. Plain objects and arrays print as JSON.
Values that cannot be serialised print as a descriptor rather than being flattened: `(undefined)`,
`(node body)`, `(function open)`, `(Object)` for a circular structure. A descriptor means "here is
what it is", not "empty" — to get at contents, return primitives:
`document.body.className`, `el.getBoundingClientRect().width`.
**Nothing fails silently any more.** Flag validation, `navigate`, and `menu` all self-report
(both `navigate` and `menu` poll the state they changed and exit 1 if it did not move — R5), and
`screenshot --diff` turns "the CSS computed but never painted" into an exit code. The one
judgement left to you: a diff proves *something* changed, not that it changed *correctly* — look
at the image when the answer matters.
**Streams.** `navigate`, `menu`, and `watch` print their human-readable result to stderr, so
piping their stdout yields nothing — **unless you pass `--json`, which puts the result object on
stdout like every other command.** Prefer `--json` whenever you intend to read the outcome
programmatically. `logs` prints its banner to stderr and log lines to stdout, so
`logs 2>/dev/null | grep …` is safe.
**Always-quote `eval`.** Wrap the whole expression in single quotes so the shell cannot split it
and so `--` inside CSS custom properties is not parsed as a flag. Object literals need parentheses:
`'({ a: 1 })'`.
---
## 6. Failure Ladder
**Run `doctor` first** — it walks rungs 1–8 automatically, in dependency order, and prints the
remedy for whatever failed:
```bash
node $S doctor
```
It also reports state this tool may have left behind (injections, the `console.error` shim). Use
the table below when `doctor` passes but something still looks wrong, or to understand a remedy
it gave you. Work top to bottom; stop at the first rung that resolves the symptom.
| # | Symptom | Check | Action |
|---|---|---|---|
| 1 | `Steam is not running with remote debugging enabled` | `curl -s http://localhost:8080/json/version` | If it answers, the port differs — retry with `--port`. If not, rung 2. |
| 2 | No CDP endpoint at all | `ps aux \| grep -i steam` | Steam not running → launch it (§7 Phase 0). Steam running *without* debug flags → rung 3. |
| 3 | Steam running, port closed | — | Flags are only read at startup; a running instance ignores them. **Ask the user before restarting (R9)**, then relaunch with debug flags. |
| 4 | `SharedJSContext not found` | `targets` | Steam is still booting. Wait and retry; do not relaunch. |
| 5 | `Webpack bundle: ✗` | `status` | UI still loading. Wait and retry. Persisting → rung 3. |
| 6 | `Steam init done: ✗` | `status` | Signed out, or stuck on login/update. Ask the user to complete sign-in. |
| 7 | `{"error": "GamepadUIMainWindowInstance not found"}` | `page`, `stores` | Steam was launched without `-gamepadui`. These commands need Big Picture Mode. |
| 8 | `{"error": "window.SteamUIStore not found"}` | `stores` | Same as rung 7, or command ran against a non-shared target. |
| 9 | `No target matching "X"` | `targets` | Use an exact name from `targets` output. `Store` resolves only when a store window is open. |
| 10 | `styles` returns `{"error": "No element matches"}` | `page` | Wrong route or wrong target. Navigate to the feature, then query the window that owns it. |
| 11 | `webpack` finds nothing (exit 1) | — | Pattern too long or wrong case. Shorten it, add `--ignore-case`. Minified builds rename most identifiers. |
| 12 | Injected CSS has no visible effect | `styles` | Computed value present but nothing on screen → CEF paint trap. See `reference/injection.md`. |
| 13 | `--target is not supported by "<cmd>"` (exit 2) | — | That command always uses the shared context. Drop the flag; for window-specific work use `eval`, `styles`, or `module`. |
| 14 | `--level`/`--limit` rejected (exit 2) | — | Invalid flag value. Use `all\|warn\|error`, or a positive integer. |
| 15 | `Route unchanged (…) — no-op` (exit 1) | `page` | That alias does not move Big Picture; `account`, `chat`, and `friends` never do. Pick a route that exists. |
| 16 | Injected code runs, nothing changes, console clean | `logs --source backend` | The backend refused a `SteamClient` call. Look for `RaiseJSException: Method call failed` — it names the method and the reason (R11). |
| 17 | `CDP connection dropped` mid-stream (exit 1) | `status` | Steam crashed or restarted. `watch` prints the backend tail it captured; that tail is the traceback. Then rung 18. |
| 18 | Steam is gone, or the UI is wedged | `status` | `restart js --confirm` for a wedged UI; `restart client --confirm` when the client itself died. Both need consent (R9). Re-inject afterwards — nothing survives. |
| 19 | `Backend channel unavailable` | `targets` | `SteamClient` lives only in `SharedJSContext`. Drop `--target`, or narrow to `--source console`. |
---
## 7. Execution phases
### Phase 0 — Preflight *(mandatory, R1)*
```bash
node $S doctor # or: node $S status
```
`doctor` exits 0 only when everything needed is in place, and names the remedy otherwise —
prefer it. If you use `status` instead, proceed only when both `Webpack bundle: ✓` and
`Steam init done: ✓`. Anything else → §6.
If Steam is not running, launch it — killing an existing instance first only with user
consent (R9), because launch flags are read only at startup:
| Platform | Command |
|---|---|
| macOS | `open -a Steam --args -dev -windowed -cef-enable-debugging -gamepadui` |
| Linux | `steam -dev -windowed -cef-enable-debugging -gamepadui` |
| Windows | `steam.exe -dev -windowed -cef-enable-debugging -gamepadui` |
| Steam Deck | Settings → System → Developer → CEF Remote Debugging, then `reference/remote.md` |
`-cef-enable-debugging` is the only strictly required flag; it opens CDP on port 8080 (8081 on a Steam Deck). `-dev`
enables verbose logging, `-windowed` avoids fullscreen, `-gamepadui` selects Big Picture Mode
and is required for `page`, `menu`, and `stores`.
Steam can take 30–90 s to reach `Steam init done: ✓`. Poll `status`; do not relaunch.
### Phase 1 — Scope
State in one line what will be answered or changed, and which window owns it. Route via §3.
Classify the task as **inspect** (read-only) or **inject** (mutates the user's running client).
For inject tasks, name the removal path before writing anything.
### Phase 2 — Locate
Establish position before drawing conclusions:
```bash
node $S targets # which windows exist
node $S page # current route + open menu (R5, R7)
```
Then locate the subject:
| Looking for | Command |
|---|---|
| Bundle source | `webpack <pattern>`, then `module <id>` |
| The real class name behind a readable one | `classes <ReadableName>` |
| A rendered element's computed style | `styles <selector> --target <win>` |
| The shape of a subtree | `dom <selector> --target <win>` |
| Application state | `stores`, then `eval` for specifics |
Depth in `reference/targets.md`.
### Phase 3 — Act
**Inspect:** run the narrowest command that answers the question. Prefer `styles` over `eval`
for CSS, `webpack` + `module` over guessing at bundle contents.
**Inject:** write the CSS or JS to a file, then let `inject` handle namespacing and reversal (R8):
```bash
node $S inject css theme.css --target BigPicture # id defaults to the filename: "theme"
node $S inject list --target BigPicture # what is currently injected
node $S inject remove theme --target BigPicture # undo, running any teardown
```
A JS file should `return` a teardown function; `inject` stores it and calls it on removal.
Without one, the change cannot be undone except by reloading.
For iterative work, `watch` re-injects on every save until interrupted — a failed edit is
reported and the loop continues:
```bash
node $S watch css theme.css --target BigPicture
```
Full playbook in `reference/injection.md`.
### Phase 4 — Verify
Never report a result straight from the command that produced it. Confirm independently:
| Action | Verification |
|---|---|
| `navigate` | Self-verifying — exit 0 and the reported route are the check |
| `menu` | Self-verifying — it polls `openMenu` and fails if the state did not change |
| CSS injection | `styles` for the computed value, then `screenshot` for the actual paint |
| JS injection | re-read the value through a fresh `eval` |
| Error fix | `logs --level error` stays clean through a reproduction |
| Any command | exit code is 0 and, for JSON, the payload has no `error` key (R4) |
**A computed style is not proof of a visible change.** CEF drops some paints entirely, so `styles`
can report exactly what you asked for while the screen is unchanged. Any visual claim must be
backed by `screenshot`, which captures what the compositor actually painted:
```bash
node $S screenshot --target BigPicture --out before.png --settle
# …make the change…
node $S screenshot --target BigPicture --out after.png --settle --diff before.png
```
`--diff` answers "did that actually change anything?" without a human looking: it reports the
changed pixel count, percentage, and bounding box, and **exits 1 when the images are identical**.
That makes an invisible change a detectable failure rather than a silent one.
**Always pass `--settle` when diffing, and check the flag it returns.** Big Picture keeps
repainting after a route change — the library loads artwork progressively — so an unsettled
baseline registers that animation as your change. `--settle` waits for consecutive identical
frames; if it reports `settled: false` the screen never stopped moving, and **the diff is not
trustworthy** — wait and retry before attributing any difference to your change.
`screenshot` cannot capture `QuickAccess`, `MainMenu`, or `NotificationToasts` — those are
browser views composited outside the page, and CDP capture hangs on them. A Big Picture capture
shows their backdrop effect but not the panel itself.
### Phase 5 — Recover *(when the client breaks under you)*
Injecting into a live client sometimes takes it down. This phase exists so that costs one command
each, not a conversation with the user.
**Diagnose.** Steam's backend is reached over CDP like everything else — no terminal launch, no
SSH, nothing installed:
```bash
node $S logs --source backend --level error # live
node $S console list 'log|dump' # what this build's dev console offers
node $S console app_status 570 # ask the client directly
```
Read this before concluding that injected code "did nothing" (R11). A `SteamClient` call the
backend refuses returns cleanly to JavaScript and reports the real reason only here.
**Detect.** Both streaming commands exit 1 when the CDP connection drops, and `watch` prints the
backend lines it captured before the drop — after a crash that tail is the only copy left, since
the client that held the rest is gone. `status` exposes `contextStarted`: a changed value between
two calls means the UI restarted and every injection went with it.
**Recover.** Ask the user, then take the smallest step that works (R9):
| Situation | Command |
|---|---|
| UI wedged, client alive | `restart js --confirm` — about a second, keeps CDP, works over `--host` |
| Client crashed or unreachable | `restart client --confirm` — shuts down if needed, relaunches with debug flags, waits for ready |
| Remote device | `restart js` only; `restart client` cannot start a process on another machine |
`restart` refuses while a game is running, and `restart client` also refuses during a download.
Do not reach for `pkill` — it bypasses both guards.
Then re-apply the injection: **nothing survives a restart of either kind.**
### Phase 6 — Report and clean up
**Definition of done.** Do not report a result until every line below is true. If one cannot be
satisfied, say which, and say what is therefore unproven — never soften it into confidence.
| # | Gate | Evidence required |
|---|---|---|
| D1 | Preflight passed this session | `doctor` exit 0, or `status` with both ✓ (R1) |
| D2 | Every command you are citing exited 0 | the exit code, plus no `error` key in JSON (R4) |
| D3 | Every build-specific value came from this session | the command output it was read from (R6) |
| D4 | Every mutation was independently confirmed | the Phase 4 check for that action type |
| D5 | Every **visual** claim is backed by a paint | `screenshot`, with `settled: true` if diffed |
| D6 | Nothing was left mutated without disclosure | `doctor`, which lists injections and shims |
| D7 | No credential or account identifier is in the report | reread the output you are about to paste (R12) |
Remove every probe artifact you introduced, or hand the user the exact removal command.
Then report:
- what was found or changed, with values quoted verbatim (R10);
- which window it applies to;
- that injected changes disappear on reload/restart (R8);
- anything left mutated — `errors` permanently patches `console.error`, most commands cache
`window.__steam_debug_wr`, and an interrupted `logs`/`watch` can leave
`window.__steam_debug_spew` registered. All clear on reload, and `doctor` reports each of them.
---
## 8. Reference index
Load on demand; do not read them all up front.
| File | Read it when |
|---|---|
| `reference/commands.md` | Full flag semantics, per-command output shapes, worked examples |
| `reference/targets.md` | Choosing a window; popup internals; routes and navigation |
| `reference/injection.md` | Writing CSS/JS into Steam; CEF paint traps; plugin patterns |
| `reference/troubleshooting.md` | Log sources, error-pattern tables, React error decoding, crash recovery |
| `reference/steam-client-api.md` | Calling a `SteamClient` API the CLI does not wrap; index into `docs/steam-client/` |
| `reference/remote.md` | Steam Deck / SteamOS over the network |
| `reference/launch-options.md` | **Rarely.** Only when changing how Steam is launched, or hunting a capability the CLI lacks. Phase 0's four flags cover normal work |
---
## 9. Maintenance & drift checklist
Run after any change to `SKILL.md`, `reference/*.md`, or `steam-debug.mjs`.
```bash
node --test test/skill-lint.mjs # offline: docs vs implementation
node --test test/smoke.mjs # live, desktop
node test/run-devices.mjs desktop deck # live, both devices
```
The smoke suite runs against either client. `STEAM_DEBUG_DEVICE=deck` points it at a Steam Deck
(`STEAM_DECK_HOST`, port 8081), and every command picks up that device's `--host`/`--port`
automatically. A Deck is never launched or restarted by the suite.
`skill-lint.mjs` fails the build when documentation drifts from code. It asserts that every
command and flag named in the docs exists, that §4's `--target` column matches the `COMMANDS`
registry, that the registry agrees with how each handler opens its session, that every declared
flag is actually parsed and every parsed flag is claimed by some command, that §4 covers every
implemented command, and that no §4 row still documents exit 0 as a failure.
It also enforces checklist item 4b in both directions, by walking the call graph from each
handler: a command whose handler can reach `EXIT_FAIL` **must** document an exit-1 condition,
and a row claiming exit 2 **must** have a `UsageError` behind it — either thrown by the handler
or raised by `validateOpts` for a flag specific to that command. Universal flags do not count as
justification; `--port` is value-checked for every command, so counting it would hand each row a
free excuse. This is the check that would have caught the stale `menu` and `watch` rows.
Manual review — confirm each still holds:
| # | Check | Fails if |
|---|---|---|
| 1 | §4 table matches `help` output exactly | A command was added or renamed |
| 2 | §5 exit-code table matches the constants in `steam-debug.mjs` | The 0/1/2 split changed |
| 3 | §5 "Nothing fails silently any more" lists only genuine remaining gaps | A quiet failure was fixed, or a new one appeared, and the paragraph was not updated |
| 4 | Every hard rule is checkable against real output | A rule became aspirational |
| 4a | No §1 rule contradicts §4, §5, or Phase 4 | A command gained self-verification and only one section was updated. R5 was wrong about `menu` for exactly this reason |
| 4b | *(automated — see above)* Every §4 row lists **all** non-zero exits the handler can set | A handler gained an `EXIT_FAIL` path and the table still shows only the usage error |
| 5 | No build-specific ID (module id, class name, React version) appears as fact | Someone pasted a real ID into the docs |
| 6 | Every `reference/*.md` in §8 exists and is linked | A file was renamed or orphaned |
| 7 | Phase 0 flags match the launch table in `README.md` | The two drifted apart |
| 8 | Every behaviour documented here was observed, not assumed | Someone wrote down what they expected rather than what happened |
| 9 | Claims about what CEF paints are backed by a `screenshot`, not by `styles` | Someone documented a computed value as a visible change |
| 10 | No command or doc has drifted into persistence or plugin loading | Scope creep — that belongs to a plugin loader, not here |
| 11 | Anything described as a device difference was checked on **both** a desktop client and a Deck | A single-device observation was written up as a platform rule. This has caused two wrong claims already — lazily-created targets and popup layout both look like platform differences until you check |
| 12 | No doc tells the user to relaunch Steam from a terminal to read backend output | That instruction is obsolete — `logs --source backend` reads the same stream over CDP, and it is the only way that works on a Deck |
| 12a | No doc instructs `pkill` as a routine step | R9 forbids it for the agent; `README.md`'s test-setup section still names it for a human, and that split must stay explicit |
| 13 | `restart client` still relaunches Steam itself rather than calling `SteamClient.User.StartRestart` | Steam's own restart strips `-cef-enable-debugging`, so the client returns alive and unreachable. If someone "simplifies" this, the crash-recovery loop silently stops working |
Adding a command: implement it, extend §4, add a smoke test, and re-run both suites.
Do not document behaviour you have not observed on a running client.