# Reolink CLI

> Provides the only correct mechanism for operating Reolink network cameras locally — the installed `reolink-cli` binary plus its gateway. **You MUST consult this skill whenever the user wants to do anything with an IP / network / surveillance / doorbell camera on their LAN, even if they never say "Reolink"** — e.g. "front-door cam", "garage camera", "IP camera at 192.168.x.x", "make it brighter", "stream URL", "too dark", "upside down", "sound the siren", "scare them off", "arm the house", "i am leaving", "set it to away mode", "disarm", "lower the bitrate", "change resolution", "switch to h265", "upgrade firmware", "flash firmware". Covers discovery, login, snapshots, live preview, PTZ, IR/spotlight/LEDs, image tuning, OSD, the manual siren, scene / arming modes on a hub, video encoder settings (resolution / frame rate / bit rate / H.264-H.265), motion + AI detection (person/vehicle/pet/package), recording + SD-card status, VOD search/download, alarm events, user accounts, reboot, firmware upgrade, and RTSP/R

- Skill: `reolink/reolink-cli` (Agent Skill, multi-file: 18 files)
- Install (CLI): `npx skillmds@latest add reolink/reolink-cli`
- Raw SKILL.md: https://api.skillmd.com/api/skills/reolink/reolink-cli/raw
- Safety review: pending (external: skill-scanner PASS, skillspector WARNING)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: reolink (https://skillmd.com/u/reolink)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/reolink/reolink-cli

---


# Reolink Camera Operator

## Overview

Primary surface: `reolink-cli` (JSON stdout by default). Don't start the MCP server unless asked. When the user needs non-CLI access (browser, curl, other languages), point them at the gateway's `POST /api`.

**Slash commands (Claude Code only)** — prefer these for fixed/single-action intents; skip the intent-mapping overhead:

| Slash | Equivalent | When to use |
|---|---|---|
| `/reolink-cli:status` | `reolink-cli status` | user asks about current state / dashboard / "how are things" |
| `/reolink-cli:features` | `reolink-cli features` | user asks what the plugin can do / "what can it do" |
| `/reolink-cli:scan` | `reolink-cli discover` | scan local network / "scan". **Finds only devices that are awake and have their own LAN address** — a sleeping battery camera does not answer (the responder lives in the main application, which is powered down), and a camera paired to a hub/NVR has no address at all: use `device expand <parent>` for those. |
| `/reolink-cli:devices` | `reolink-cli device list` | list registered cameras |
| `/reolink-cli:cache-clean` | `reolink-cli cache clean` dry-run→apply | clear old snapshots / "clear cache" |
| `/reolink-cli:update` | `reolink-cli self-update` | upgrade to the latest release (checks GitHub, no-op if current) |
| `/reolink-cli:uninstall` | `reolink-cli setup --uninstall --purge --no-interactive` | complete uninstall |

**Claude Code runtime:** **Must** use the slash command when the user's intent matches one of these seven exactly — faster (no LLM latency), deterministic, and discoverable via `/` autocompletion.

**Other agents (Codex / Cursor / Copilot / Gemini):** these surfaces don't support plugin-defined slash commands, so the "slash-first" rule doesn't apply — skip straight to running the equivalent `reolink-cli …` CLI command directly. Skill-driven intent mapping handles everything else.

**All agents:** **Fall back to skill-driven CLI chaining** for compound / fuzzy intents ("announce when a person is detected", "make it brighter") that aren't covered by the slash list / seven fixed actions above.

**The gateway is mandatory for almost every control command.** The CLI routes through `127.0.0.1:9000` by default (set in `config.toml` under the platform config dir — `~/.config/reolink-cli/config.toml` on Linux, `~/Library/Application Support/reolink-cli/config.toml` on macOS, `%APPDATA%\reolink-cli\config.toml` on Windows — as `gateway-addr`). If you see `gateway connect failed: Connection refused`, the gateway isn't running — start it once:

```bash
reolink-cli gateway start --addr 127.0.0.1:9000 &
```

Works **without** the gateway: `device add|list|update|remove|resolve|show`, `config init`, `discover`, `features`, `doctor`, `cache status|clean` — everything that reads local config or talks to the network directly. (Verified by running each with no gateway listening.) Everything else (`ping`, `login`, `info`, `config get/set`, `image`, `osd`, `ptz`, `detect`, `light`, `audio`, `preview`, `snapshot`, `vod`, `events`, `users`, `system reboot`, `system upgrade`) needs the gateway up.

**Envelopes** — parse `.ok` on CLI, `.status` on gateway:

| Surface | Success | Error |
|---|---|---|
| CLI | `{ok:true, command, protocol, data}` | `{ok:false, error:{code, message, retryable}}` (stderr) |
| CLI batch | `{command, summary, results:[{camera, host, ok, data, error}]}` | per-target `ok` |
| Gateway `/api` | `{status:"success", data}` | `{status:"error", code, message}` (HTTP status == code) |

`references/` has topic-scoped recipe files. **Must** load only the one matching your task — `references/index.md` is the router. `references/troubleshooting.md` has diagnosis decision trees for failures.

## Ground Truth (Binding)

**Forbidden** stating or implying as factual: device reachability, login state, configuration values, capabilities, detection/AI types, event counts, VOD file existence, user accounts, or any observable camera state — **except** when derived from the JSON output of an **executed** `reolink-cli` command or gateway `POST /api` response within the current task.

**Forbidden**: demo-style lists, guessed layouts, synthetic values, fake success after errors/timeouts, prose/JSON mimicking CLI output without a real response, or "the device has X" when no `get`/`info`/`inventory`/`capabilities` has run.

**Must** when data is missing: state what's missing and the cause (no camera registered, gateway down, auth failed, device offline), then take one concrete recovery step (from Error Recovery table) or ask a single clarifying question. **Forbidden** padding with plausible-sounding defaults.

## Intent Interpretation

Resolve ambiguity before picking a command. If still unclear, **ask with options**.

| User says | Command | Why / caveat |
|---|---|---|
| add a user | `users add` | Device account, NOT a CLI camera entry |
| add a camera | `device add` | CLI camera entry (`~/.config/reolink-cli/aliases.toml`), NOT a device account |
| change password | `users passwd` (default) OR `device update` (CLI creds) | Ask which |
| blinking light | `light statusled` | Body LED |
| night vision | `light ir` | Invisible IR LED |
| spotlight | `light spotlight` (manual) OR `light whiteled` (alarm-triggered) | Same bulb, different wiring |
| too dark | `image tune --bright` first, check `light ir get` if night | Image tuning, not spotlight |
| upside down | `image flip set --flip` | Vertical |
| mirrored | `image flip set --mirror` | Horizontal |
| mask | `privacy mask` | Read current regions first |
| rename | `osd set --name` (on-screen) OR `config set device-name` (app) | Ask which |
| restart / reboot | `system reboot` | 30–60s offline |
| upgrade / flash firmware / OTA | `system upgrade <file.pak>` | ⚠️ bricking risk. Match model+hw_ver via `info`; GATEWAY reads the LOCAL file; auto windowed/stop-and-wait. Detail in `references/admin.md` |
| factory reset | — standalone; `system upgrade --factory-reset` resets *during* a flash | Standalone reset not in CLI; ask user to use Reolink app |
| kick off | `users remove` (permanent) OR `users passwd` + `system reboot` (evict session) | Active sessions survive `users remove` until reboot |
| snapshot | `snapshot [--file PATH]` | JPEG. **Parent directories are auto-created** by the binary (since v0.5.0) — do NOT pre-`mkdir -p` the destination. Same for `vod download` and `preview capture --file`. |
| **slow / how long / time taken / performance / latency / benchmark** | **`benchmark [--iterations N] [--phases ...] [--reuse-session]`** | Per-phase p50/p95/p99: connect, login, info, snapshot. `--reuse-session` for warm-path. Detail in `references/index.md`. |
| **cpu / memory / device load / is the camera overloaded / stuttering** | **`config get performance`** | Live reading from the device: `cpuUsedPercent`, `codeRate`, `netDataRate`. Read-only and instantaneous — two calls a second apart legitimately differ. Not every model implements it; those answer a device-level rejection. Distinct from `benchmark`, which times the **client** round trip, not the camera. |
| **rtsp / rtmp / flv address / stream URL / HA / Frigate / go2rtc / VLC** | **`stream url [--kind rtsp,rtmp,flv] [--stream main,sub,ext] [--with-auth]`** | Default `--kind rtsp --stream main`. `--with-auth` only when user explicitly wants one-shot pasteable URL. NVR: `device expand` then `--tag <nvr>`. Detail in `references/media.md`. |
| nvr with 8 channels / RLN sub-cameras / channel N / **cameras paired to a Home Hub** | `device expand <parent-name> [--yes \| --names A,B,C]` | Any **v20** parent — RLN NVRs and a Home Hub alike. Registers one entry per populated channel, tagged with the parent name; after: `--tag <parent>` fans out. **This is the only way to reach a battery camera paired to a hub** — it has no address of its own, so `discover` cannot see it. Refused for `protocol=v30` entries. |
| command rejected 400 / "model doesn't support it" / battery camera on a hub / first command after a pause | Nothing — the gateway already retries. `attempts` in the answer says which try got through (3 is normal after a pause, 1 during a burst) | A battery child behind a hub sleeps and then rejects commands with a bare 400, indistinguishable from unsupported. **When** it sleeps is not predictable from idle time alone (one day's readings said ~2 min; later overnight windows found it awake), so do not try to pre-empt it. What is consistent: a sleeping camera costs a flat 3 attempts (~1.6 s) to wake, and the refused request is itself the wake. If it still fails after the budget, `info` → `channel.loginState` = `standby` explains it — but **that field lags**, so it explains a failure, it cannot pre-flight one. |
| dual-lens / two lenses / bullet+PTZ in one camera / wide and telephoto / second lens | `info` first (`channel.views`, `channel.dualLens`), then `--view N` on config commands | `--view` is a **third** axis: `--channel` picks the camera, `--view` picks the lens, `--stream` picks that lens's encoding profile. Verified per-view: `encode`, `privacy mask`, `detect motion`, PTZ. **Not** per-view: `snapshot` (device answers 400 for view≥1), `preview`/`stream url` (protocol has no selector — these now **reject** `--view N` rather than silently handing back view 0), image/ISP (shared). Default 0 = the only view an ordinary camera has. |
| **preview / take a look / watch N minutes / watch live** | **`preview play`** (opens ffplay window) | **DEFAULT to `play`, not `capture`.** User wants a live window, not a file. |
| record N seconds / save a clip / save it | `preview capture --packets ≈N×25` | Only when user explicitly says save / record / export. Packet count, not seconds; ~25 fps main, ~10 fps sub |
| 60-second preview (ambiguous!) | If user says "preview for N seconds/minutes" → `preview play --packets N×fps`. If user says "record N seconds" → `preview capture`. Ask if truly unclear. | Default to `play` when the verb is "preview/watch", `capture` only when the verb is "record/save/export" |
| volume | `audio volume set` (master) | Ask if they mean mute: `audio mute` |
| mute alarm | `audio mute` | Alarm audio only, not preview |
| **siren / alarm sound / sound the alarm / scare them off / make it beep** | **`audio siren play [--duration N \| --times N] [--wait]`** | Loud + side-effectful — confirm first. cmd 263 manual ring-down (the app's siren button); device self-stops after `--duration` s, or `audio siren stop`. `--wait` holds for the duration then stops explicitly and reports `stoppedAt` + `stopAcknowledged` — use it when you must be able to say the siren stopped, not just that it started. **A 400 from a battery camera behind a hub is not evidence about the hardware** — a sleeping child answers 400 exactly like an unimplemented command. (Separately: on one Home Hub 2 the siren was measured not to sound at all even when the command succeeds — see `verified: false` below. Never claim it sounded.) The gateway retries (measured: a camera idle >2 min needs 3 attempts, ~1.6 s), and `attempts` in the answer tells you which try got through. If it still fails, `info` → `channel.loginState` = `standby` explains it — but that field lags, so it is for explaining a failure, not for checking before one. Never tell a user their camera lacks a feature on the strength of a 400; `capabilities` is what answers that. Same rule for `light spotlight`. "siren when motion is detected" is different — that is `audio siren task` linkage (often 405 on newer models). |
| **arm / disarm / away / at home / i'm leaving / scene mode / stop recording while i'm in** | **`scene set <id>`** (`scene list` for the ids, `scene set --schedule` to hand back to the timetable) | Hub/NVR only. One switch re-arms every channel — it does **not** touch per-camera detection config. `scene show` first: `currentSceneId` 0 means the timetable is driving. Detail in `references/scene-mode.md`. |
| **resolution / bitrate / fps / frame rate / h265 / h264 / stream quality / make the stream smaller** | **`encode set --stream main\|sub\|third ...`** | **Run `encode capability` first** — a value the device does not advertise comes back as a bare 400. `stream url` is unrelated (that only builds a playback URL). |
| person detection | `detect ai --type person` | Check `device inventory --capabilities` first |
| vehicle | `detect ai --type vehicle` | Not all models |
| turn on recording | `record schedule set --enable` | |
| record config / recording params / pre-record seconds / clip size | `record config get` | Read-only; cycle / pre/post record / package time |
| SD card / storage card / capacity / free space | `storage status` | Read-only; totalGB / remainGB / formatted / mounted |
| manual record / record now / start recording | — | **Not supported** on Reolink IPCs; fall back to `record schedule set --enable/--disable` |
| download yesterday / download recording | `vod search --since 24h` → `vod download NAME` | |
| download 09:45–09:55 / just that clip / a time range | `vod download --from 2026-09-02T09:45:00 --to 2026-09-02T09:55:00` | Camera-side cut — do NOT download the whole hour and trim |
| any alarms / any alarms today | `events query --since 24h` | Requires gateway |
| event history further back than the live buffer / what happened last week / hub event log | `--channel N events history --since 7d [--types people,motion]` (hub/NVR only) | Reads the device's recorded event log (cmd 516/517), not the ~500-entry live ring. Standalone IPCs 400. Empty list = no matching events; a hub can go briefly quiet after heavy use, retry after a pause. |
| voice alert / voice announcement / announce when someone arrives / play voice when a person is detected | `audio talk` (see `references/voice-alert.md`) | PCM16 LE mono only; needs `capabilities.audioTalk=1` |
| detect X do Y / automation / event trigger / detect-then-do | **Must** `events monitor init/check/run` with TOML rule. **Forbidden** ad-hoc bash `while + events query` loops — see `references/event-monitor.md`. |
| is the monitor running / has a rule fired / monitor status / what fired recently | `events monitor status` (pid + cursor + lastFires + per-rule `expired`/`secondsRemaining`) or `events monitor history --last N [--rule NAME] [--since 30m]` | Inspect the running daemon without restarting. Filters apply **before** `--last`, so `--rule watch --last 20` is that rule's last 20 fires, not whatever survives the global tail. |
| watch this camera for the next 30 minutes / temporary rule / bounded monitoring task | add a rule with `expires_at = "<RFC 3339 or Unix second>"`, `events monitor reload`, then read back with `events monitor history --rule NAME` | Absolute time only — a relative `"30m"` would restart its countdown on every reload. Expiry stops the rule firing; it does **not** rewrite the rules file, and captured history is kept. |
| apply edited rules / reload rules | `events monitor reload` (Unix SIGHUP) | Re-reads TOML without dropping cursor. Invalid TOML keeps old rules + logs warn. On Windows, stop + run again — SIGHUP is Unix-only. |
| what can it do / what features / what's supported / `what can this CLI do` / what's new | `reolink-cli features` (add `--output text` for human view) | Lists installed commands + highlights since v0.2.8 + XDG paths. **Must** run this first to gate which subcommands exist on the user's binary |
| how much disk used / cache size / cache usage | `reolink-cli cache status` | Inspect `~/.cache/reolink-cli/` by category (snapshots/audio/captures/downloads/logs) |
| current status / how are things / system health / overview / dashboard | `reolink-cli status` | Fleet + gateway + events-monitor + cache. `--camera X` to include recent events. |
| is the gateway running / gateway status | `reolink-cli gateway status` | 500ms TCP probe; on `[DOWN]` prints exact `gateway start` to run. |
| check / diagnose / doctor / what's wrong / sanity check | `reolink-cli doctor` | 9 offline checks (binary, dirs, config, registry, perms, gateway TCP). Use FIRST when user says "broken". |
| tab completion / shell completion | `reolink-cli completions {bash\|zsh\|fish\|powershell\|elvish} > <path>` | Re-run after `self-update`. |
| **first-time setup / configure camera / set up reolink / how to use after install / how to get started** | **`reolink-cli config init`** then **`device add`** | `config init` writes default gateway addr; `device add <alias> --host <ip> --user admin` registers the first camera (password via prompt or `--password-stdin`). Then `--alias <name> login` to verify. |
| **uninstall / delete / remove reolink** | **`reolink-cli setup --uninstall --purge --no-interactive`** | **Confirm with the user before running this — `--purge` deletes their camera registry and cannot be undone.** Without a terminal on stdin — which is every command you launch — the uninstall refuses unless `--no-interactive` is present; that flag is the declaration that the deletion was intended, so do not add it reflexively to make an error go away. Removes binaries + config/cache/state + cross-agent skill dirs + Claude Code plugin registry. Drop `--purge` to keep config/cache/state (alias list preserved) — there is no `--keep-config` flag. The agent skill dirs are global (`~/.claude/skills/…`, `~/.agents/skills/…`): they are removed regardless of `REOLINK_PREFIX`, so uninstalling one copy of a side-by-side install unlinks the skill for both. If the customer still has the extracted tarball, `./uninstall.sh` in there is the symmetric alternative (it forwards straight to this same command; the uninstall flow only honours `--purge`, `--no-interactive` and `--prefix`). (Note: `npx skills remove reolink-cli` is rarely needed — `reolink-cli setup --uninstall` already wipes the agent skill dirs.) |
| skill stale / plugin cache stale / refresh skill | `reolink-cli plugin refresh` | Auto-detects agent (Claude Code / Codex / Cursor / Copilot / Gemini). Run when `features` reports `cache_state != in_sync`. |
| clean up / clear cache / delete old snapshots / clean cache | `cache clean [--older-than 7d] [--category X] [--apply]` | Dry-run by default — always preview first, run `--apply` only after the user sees the list |

## Workflow

**Default: just run the command.** The gateway daemon caches auth across CLI invocations, so the agent doesn't pay ping+login per command. Most user intents map to **one** CLI call (`info`, `get`, `apply`, `snapshot`, `stream url`, …).

**Forbidden** pre-fetching `<subcmd> --help` to "check what flags exist." The *Intent Interpretation* and *Command Reference* sections below, plus `references/<topic>.md`, already list every subcommand and its key flags; `reolink-cli features` enumerates the installed surface at runtime. **Must** dispatch the operative command directly. Only read `--help` if the command actually errored with an unknown-flag clap message. Each unnecessary `--help` round adds one full agent turn (~2–3 s of perceived user latency) for zero information gain — the user feels it, the CLI doesn't.

**Must** batch a single user intent into a single shell invocation when it needs multiple CLI commands. For sequences like "PTZ full sweep" (right→down→left→up→stop), "snapshot before/after a move", "info + capabilities + storage status", **or "health check + benchmark" / "doctor + benchmark" pairs**, chain them with `&&` (or `;` if you want continue-on-error) inside one Bash call — do **not** spawn N separate tool calls. Each extra tool call adds one full agent turn (~2–3 s). N=5 commands as one Bash = ~3 s perceived; N=5 commands as five tool calls = ~12 s perceived. The rule applies even when the commands are semantically distinct (e.g. `doctor` is local, `benchmark` hits the device) — the user asked for "both", so dispatch both in one shell line: `reolink-cli doctor && reolink-cli --camera X benchmark --iterations 3`. Independent prep checks for *unrelated* devices can still be parallel tool calls; the rule is about *one intent → one invocation*.

**MCP server is the fastest path for repeated calls.** `reolink-cli mcp-server` speaks JSON-RPC 2.0 over stdio (MCP protocol 2025-11-25, ~39 tools): identity (`camera_{ping,login,info,capabilities,discover}`), PTZ (`camera_ptz_{move,stop,presets,preset_goto,preset_set,preset_delete}`), light (`camera_light_{ir,statusled}_{get,set}`, `camera_light_spotlight_set`), audio (`camera_audio_{volume_get,volume_set,mute}`), detection (`camera_detect_{motion,ai}_{get,set}`), recording/storage (`camera_record_config_get`, `camera_storage_status`, `camera_vod_search`), events (`camera_events_query`), image/OSD (`camera_image_{flip,tune}_get`, `camera_image_flip_set`, `camera_osd_{get,set}`), users (`camera_users_list`), system (`camera_system_reboot`), plus `camera_config_{get,set}`, `camera_preview_capture`, `camera_snapshot` (JPEG to a file), and `camera_raw`. Benchmarked locally: 5 sequential `camera_info` calls take 52 ms via MCP vs 108 ms via 5 Bash spawns — ~52% faster, saving ~11 ms per call by avoiding process startup. **When to suggest MCP wire-up**: the user is going to do >3 ops in one conversation, or runs the agent in a tight loop. **How to wire it in Claude Code** (claude.json or settings):
```json
"mcpServers": {
  "reolink-cli": {
    "command": "reolink-cli",
    "args": ["mcp-server"],
    "env": { "REOLINK_GATEWAY_ADDR": "127.0.0.1:9000" }
  }
}
```
Pass `alias`, `host`, or `uid` per tool call to target a specific camera; the server validates input schemas and returns `{structuredContent, isError}` payloads. **Caveat**: the gateway must be running separately (MCP routes through it the same way the CLI does).

**`apply` recipes are one-shot.** For "set X to Y" intents, the `*** apply` subcommand does get → compare → (skip if idempotent) → set → verify internally and returns `{before, after, changed, verified}`. **Forbidden** running `get → set → get` manually, and **forbidden** running a separate `get` after an `apply` — the verify is already inside.

| Recipe | Replaces |
|---|---|
| `light ir apply --state auto\|on\|off` | manual ir get/set/get |
| `image flip apply [--flip\|--no-flip] [--mirror\|--no-mirror]` | manual flip get/set/get |
| `osd apply [--name] [--datetime] [--name-overlay]` | manual osd get/set/get |
| `detect motion apply [--enable] [--sensitivity N]` | manual motion get/set/get |
| `detect ai apply --type T [--sensitivity N]` | manual ai get/set/get |

**`ping` and `login` are NOT pre-steps. They are diagnostics.** The first command you actually need (`info`, `get`, `apply`, `snapshot`, …) does its own connect + auth via the gateway daemon. If that command's JSON has `error.code == "auth_required"`, re-run `login` once and retry. If it has `error.code == "connection_refused"` / `"timeout"` / `"no_route"`, that IS the same signal `ping` would give — no need to run `ping` again. **Forbidden** running `ping` followed by `login` followed by the real command on every turn; that's 3 round-trips for the work of 1. Only call `ping` standalone when the user explicitly asks "is camera X reachable?" or "why can't I connect?".

**Verify only when needed.** Re-`get` after a write **only** on capability-gated config SETs that can silently keep the old state (`light ir/whiteled/spotlight set`, `image flip/tune set`, `audio config`, `detect motion/ai set`, `osd set`, `encode set`, generic `config set`). **Forbidden** verifying after `apply` (already verified), `ptz move/stop/preset`, `system reboot`, `system upgrade` (confirm via `info` after the device reboots, not a re-`get`), `snapshot`, `preview *`, `stream url`, `audio mute/unmute/talk`, `audio siren play/stop` (a one-shot action, nothing to re-read), `vod download`, `users passwd`, `privacy mask set`. Full table in `references/troubleshooting.md#verify-rules`.

**Other safety rules** (kept verbatim — these break things if violated):

- **Target selectors are global options** (pre-subcommand). **Forbidden** positional. Priority: `--camera` > `--host` > `--uid` > env. Batch: `--tag`, `--cameras A,B`, `--all-devices`. **Must** pass `--channel N` for NVR.
- **Credential safety**: **Forbidden** `--password PLAIN` on argv. **Must** use `--camera <name>` (from `aliases.toml` 0600), `REOLINK_PASSWORD` env, or `--password-stdin`. If no camera registered and op isn't trivially read-only, ask user to run `device add` first.
- **Stored passwords are encrypted** (`RLENC1:…`, AES-256-GCM) with the key in `credentials.key` **beside the file that holds the password** — beside `aliases.toml` in every layout, including a registry redirected with `--cameras-file`/`REOLINK_CAMERAS_FILE`, and beside a redirected `config.toml` if a password lives there. Separate registry files therefore have separate keys. A plaintext config is converted automatically on first use. Do **not** try to read a password out of `aliases.toml` — it is ciphertext, and there is no command that reveals it. Backing up or moving a config means copying the file **and the `credentials.key` next to it** as a pair, from the same directory; with only one of them the passwords are unrecoverable and must be re-entered via `device update <camera> --password-stdin`. If a command reports a password that "cannot be decrypted", the key file is missing or mismatched — that is not a wrong-password problem, so do not retry with guesses. (Installs from before 0.12.4 kept a single key in the default config directory; it is still read as a fallback until the next write moves it beside its file.)
- **Pre-write read**: **Must** `get` current value before any write that's NOT covered by an `apply` recipe; confirm side effects from the table below; verify NVR channel.
- **Protocol**: leave it unset — it defaults to `v20`, which the whole supported fleet speaks. A v30 camera declares `protocol = "v30"` in its entry (or `--protocol v30` for one call). `--protocol auto` asks the gateway to detect it from the device's response magic and costs an extra TCP round trip; use it only to re-detect a camera whose entry names the wrong protocol.
- **Battery devices on LAN (transparent wake)**: when `--uid` is given without `--host`, the gateway automatically runs the BC3.0 §F.2 wake handshake on UDP 2026 before login (in customer / no-P2P builds). **Forbidden** calling `reolink-cli wake` as a precondition — it is a hidden diagnostic command. **Must** just call `info` / `preview` / `ptz` / etc. with `--uid` and accept that the **first** call may take 2–8 s while the device boots; subsequent calls within the gateway session TTL reuse the live session and have no wake overhead.
- **Events monitor exclusivity**: when intent pairs trigger + action ("announce when a person is detected", "car detected → log"), **Must** use `events monitor init/check/run` with a TOML rule. **Forbidden** writing `while true; do events query…; done` shell loops — the rule engine already has debounce, parallel dispatch, SIGTERM draining, cursor bootstrapping, action-timeout, retry. See `references/event-monitor.md`.

## Global Options

`--host`/`--uid`/`--camera`/`--cameras A,B`/`--tag`/`--all-devices`, `--user`/`--password`/`--channel`/`--view`, `--protocol auto`, `--output json|text`, `--gateway-addr HOST:PORT`, `--config-file`/`--cameras-file`. Env equivalents: `REOLINK_HOST/UID/ALIAS/USER/PASSWORD/CHANNEL/VIEW/PROTOCOL/GATEWAY_ADDR/CONFIG_FILE/ALIASES_FILE`. Run `reolink-cli --help` or `<subcmd> --help` for exact flag shapes.

## Batch Operations

- **Must** keep the same selector throughout a workflow — the batch framework handles fan-out.
- **Forbidden** enumerating with `--tag` then switching to `--camera` inside the same workflow.
- Batch output has per-target `ok`; a single failure doesn't fail the batch. **Must** check `summary.failed > 0` and iterate `results[]`.

**Degenerate case:** when a batch selector (`--tag X`, `--cameras A`, `--all-devices`) resolves to *exactly one* device, the CLI emits the **single-target envelope** (`{ok, command, protocol, data}`), not the batch report. **Must** test `"summary" in response` to detect which shape you got. **Forbidden** assuming `results[]` exists.

## Error Recovery

| Error | Cause | Next step |
|---|---|---|
| `reolink-cli: command not found` | Binary not installed (installing the skill does not install the binary) | Run the install snippet in `references/setup.md` → **Install the `reolink-cli` binary**, then retry |
| `gateway connect failed: Connection refused` | Gateway not running | `reolink-cli gateway start --addr 127.0.0.1:9000 &` then retry |
| `ping` returns `reachable:false, reason:"timeout"` | IP not routable (cross-subnet / VPN not up / host off / firewall) | Tell user: verify VPN / check subnet; **do not** try `login` — it would hit the same timeout |
| `ping` returns `reachable:false, reason:"refused"` | Host reachable but port 9000 closed | Wrong port / wrong IP / gateway service not running on device |
| `ping` returns `reachable:false, reason:"no_route"` | No routing entry at all | Check `netstat -rn` / VPN |
| `ping` returns `skipped:true, reason:"uid only..."` | UID-only target | Expected — proceed directly to `login` |
| `device unreachable` (retryable) | Net drop / device off / wrong port | Re-`ping`; check sibling |
| `auth required` / `invalid credentials` | Wrong creds | `device update`; admin-reset via Reolink app if locked out |
| `invalid params` | Caller bug | Fix request, don't retry |
| `unsupported` | Firmware lacks feature | `device inventory --capabilities` to confirm; skip |
| `Connection reset` after `system reboot` | Expected | CLI swallows this one; re-ping after 30–60s |
| Remote (UID) login timeout | First-connect warm-up | Retry with `--timeout-secs 15+` |
| `wake timed out after 3 attempts: no response from device with uid X` | Battery device not reachable on the local broadcast domain | Verify device is on the same /24 as the gateway host, UDP 2026 not firewalled, device not depleted; **do not** retry tightly — each attempt already takes ~3 s and the device may genuinely be off-network |
| `device acknowledged wake (booting) but did not report ready within 30s` | MCU woke but Linux main failed to come up | Physical-side issue (battery low, firmware stuck); user must check the device |
| `no active session` (gateway) | Token expired | Re-login via `set auth.login` |
| HTTP 401 (gateway) | Missing `Authorization: Bearer` | Add header |
| HTTP 403 (gateway) | Token invalid/expired | Re-login |
| HTTP 410 on `/api/login` or `/api/request` | Old v0.1.1 endpoint | Use `POST /api` + Bearer |
| `users remove admin` fails | Device refuses removing last admin | Don't |
| `users passwd` permission error | Non-admin changing someone else's | Log in as admin |

## Command Reference

Signatures only — run `<cmd> --help` for flag details; see `references/<topic>.md` for per-feature examples.

**Discovery / Registry:** `discover`, `device list|resolve|show|add|update|remove|import|inventory|analyze|expand`, `config init`

**NVR / Hub multi-channel:** `device expand <parent-name> [--yes | --names A,B,C] [--drop-parent]`. Any v20 parent (RLN-series **and** Home Hub); one entry per populated channel, auto-tagged with the parent name. A hub's paired battery cameras are only reachable this way — they have no LAN address, so `discover` never lists them. `protocol=v30` entries are refused.

**Identity:** `ping`, `login`, `info`, `capabilities`

**Config paths** (`config get/set PATH` with merge semantics):
`led`, `device-name`, `language`, `time-zone`, `time-format`, `network`, `osd`, `osd-format`, `system-general` (get-only).
*(The paths `image` / `audio` / `alarm` / `alarm-policy` / `ai` were removed — they were stubs. Use dedicated commands below.)*

**Preview / Snapshot:** `preview capture|play|start|stop`, `snapshot [-o FILE] [--stream]`. `--stream sub` (the default) is the fast path — a small ~900x512 JPEG, ~2s on a woken camera; `--stream main` is a full-resolution frame at ~5s on a 4K model. **A battery camera behind a hub sleeps (when is not predictable from idle time — see the 400 row above), and the first capture after it does waits for its whole video pipeline to boot: measured 5s (sub) to 23s (4K, three channels waking at once) before the device sends a single byte.** That wait is device-side and unavoidable — it is not a hang, and the timeout budget covers it. A capture during a burst is sub-second; treat the first one after any gap as a warm-up rather than trying to predict which one pays the wake.

**Benchmark:** `benchmark [--iterations N] [--phases connect,login,info,snapshot] [--reuse-session] [--warmup M]` — per-phase p50/p95/p99/mean/stddev. Read-only.

**Doctor:** `doctor` — 9 sanity checks (binary, dirs, config.toml, registry, perms 0600, legacy cleanup, gateway TCP). Non-zero exit on fail. **Use first** for "broken" reports.

**Completions:** `completions {bash|zsh|fish|powershell|elvish}` — emits to stdout; redirect into shell's completion dir. Re-run after `self-update`.

**Gateway status:** `gateway status` — 500ms TCP probe of the resolved gateway-addr. Text shows `[LISTENING] / [DOWN] / [UNCONFIGURED]` + the `gateway start` command on down.

**Gateway log files (since 0.12.5):** the gateway always writes a compact single-line log to `<cache-dir>/logs/gateway-<pid>.log` (`~/.cache/reolink-cli/logs/` on Linux, `~/Library/Caches/reolink-cli/logs/` on macOS, `%LOCALAPPDATA%\reolink-cli\logs\` on Windows) at `info` level; files older than 14 days are pruned at startup, and `cache clean --category logs` manages them like any other cache category. **To collect evidence after a failed run: zip the `logs/` directory — no pre-configuration needed.** For deep traces re-run with `RUST_LOG=debug` (adds per-round LAN probe results, every TCP connect attempt with address+outcome, uid-cache hits with age). `REOLINK_LOG_FILE=off` disables the file; `REOLINK_LOG_FILE=<path>` redirects it (user-managed, no pruning). Credentials never appear in logs; entries do contain hosts/UIDs/SSIDs, so redact before posting publicly.

**Stream URLs:** `stream url [--kind rtsp,rtmp,flv] [--stream main,sub,ext] [--with-auth]` — defaults to `rtsp main`. `--with-auth` embeds creds (use only when user wants pasteable URL); without it ships sibling `user`/`password` JSON fields. NVR: `device expand` then `--tag <nvr> stream url`. RTSP path is `/Preview_{NN}_{main|sub|ext}`. Detail in `references/media.md`.

**Light:** `light {ir|statusled} {get|set --state auto|on|off}`, `light ir apply --state auto|on|off` (recipe; prefer for "set X"), `light spotlight set --enable|--disable [--duration]`, `light whiteled {get|set [--enable|--disable] [--brightness 0-100] [--alarm-mode] [--detect-type T,...]}`. *Caveat:* E-series can `ok:true` on `light ir set` while silently keeping old state — `apply` recipe surfaces this in `verified:false`; check `device inventory --capabilities` if write ignored.

**Image:** `image flip {get|set|apply [--flip|--no-flip] [--mirror|--no-mirror]}` (apply is the idempotent recipe), `image tune {get|set [--bright N] [--contrast N] [--saturation N] [--hue N] [--sharpen N]}` — all 0–255, 128 = neutral. If a user says "60%" that's `~160`.

**OSD:** `osd {get|set|apply [--name NAME] [--datetime|--no-datetime] [--name-overlay|--no-name-overlay]}` (apply is the idempotent recipe)

**System:** `system reboot`; `system upgrade <file.pak> [--factory-reset] [-y]` — flash firmware (gateway reads the LOCAL file; ⚠️ bricking risk, match model+hw_ver; auto windowed/stop-and-wait by capability; detail in `references/admin.md`)

**Users (device accounts):** `users list`, `users add NAME --level admin|user`, `users remove NAME`, `users passwd NAME`. Password via TTY prompt or `--password-stdin` — never `--password PLAIN`. Name + password each 1–31 chars. Non-admin users can only change their own password.

**Audio:** `audio config {get|set}`, `audio volume {get|set LEVEL}` (0–100), `audio mute`, `audio unmute`, `audio replies`, `audio talk {--file PATH | --stdin} [--sample-rate N]`. `audio volume` is master; `audio config.volume` is per-profile — ask which. `audio talk` pushes PCM16 LE mono to camera speaker via talkback (cmd 201 open + 202 stream). Gate on `capabilities.audioTalk=1`.

**Siren:** `audio siren play [--duration 1-600 | --times 1-100] [--wait]` (default 2 repetitions ≈ 6s; device self-stops after the last one), `audio siren stop` (silence now). The device sounds in whole repetitions of ~3.2s, so `--duration` rounds **up** to `--times` and the answer reports both, plus `speakerVolume` — **if the user says they heard nothing, read that first** (a measured case sat at 7/100 and was inaudible; the command succeeded regardless). **Up to 0.14.3 the siren never actually sounded** — the CLI sent `playMode 1` (by duration), which this firmware accepts with a 200 and ignores; `playMode 0` (by times) is the one that makes noise, confirmed against the camera's own microphone. `--wait` blocks for the duration, then sends an explicit stop and adds `waited`/`stoppedAt`/`stopAcknowledged` to the answer; the firmware timer is still sent, so a killed CLI does not leave the siren running. Without `--wait` the answer is `accepted: true, verified: false` and nothing more — v20 has no GET for siren state, so **never** report that the siren sounded on the strength of it. This is the app's manual-siren button = **v20 cmd 263** `NET_MANUAL_RING_DOWN` (`<audioPlayInfo>` continuous/switch mode), verified on real RLC-823/810. Gate on `capabilities` / JSON `GetAbility.supportAudioAlarm`. Also `audio siren task {get|set --on|--off}` (which alarms trigger it) + `audio siren enable {get|set [--on|--off] [--channel-on|--channel-off]}` — **two** switches: device-wide `mainSwitch` and a per-channel `channelMask` bit. A hub can report master-on with every channel bit zero; `--on` alone cannot fix that, which is why `--channel-on` exists — **v20 cmds 306/307, 316/317, but this whole v3.x generation returns 405 for them**; only manual play/stop is universal. A sounding device pushes cmd 547 → `events stream` shows `siren.on`/`siren.off` (no GET for siren state). NB: do NOT confuse with cmd 349 (play a *stored* audio file) — it 400s on cameras with an empty audio store, i.e. almost all of them.

**Scene mode (hub arming profiles):** `scene show` (active scene + which task bits this hub honours + the ids), `scene list` (every scene with its per-channel tasks), `scene status`, `scene set <id>` / `scene set --schedule`, `scene edit <id> [--tasks record,ftp,email,push,audio,linkage,speaker,track] [--channel N] [--iot-tasks linkage,device] [--name ...] [--delay N]`, `scene schedule get|set --scene <id> [--days 0,1] [--from H] [--to H]`, `scene alarm get|set --task REC --types people,vehicle [--channel N]`, `scene options [--key-enable|--key-disable] [--private-mode|--no-private-mode]`.

A scene is a named set of per-channel task bits, so switching scenes re-arms the whole hub at once — that is the app's Home / Away / Disarm control. `audio` in the task list is the **siren**, not the microphone. Scene id `0` is not a scene: it hands control to the weekly schedule, and `scene show` reports that as `followSchedule: true`. `--tasks` **replaces** the scene's set rather than adding to it, and without `--channel` it applies to every channel in the scene. The schedule is 7 days × 2 half-hour slots × 24 hours; `--days` takes the device's own 0-6 numbering.

**Encoder:** `encode get` (all three streams), `encode capability` (what the device accepts — **read this before setting**), `encode set --stream main|sub|third [--resolution WxH] [--fps N] [--bitrate KBPS] [--codec h264|h265] [--rate-control vbr|cbr] [--profile default|baseline|main|high] [--gop N] [--smart-h265|--no-smart-h265] [--smart-h265-level 0-2]`. The wire SET (cmd 57) rewrites all three streams at once, so the CLI always re-reads first and patches only the flags you pass. Changing resolution restarts the stream — any live 

…(truncated)
