Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2c5b448
fix(devices): emit structured JSON for command --dry-run --json (bug …
Apr 20, 2026
7fae5c2
fix(mcp): preserve structured error metadata in tool responses (bug #38)
Apr 20, 2026
b5806bd
fix(mcp): narrow mcpError option types to ErrorSubKind / errorClass u…
Apr 20, 2026
ad5bfc3
fix(batch): propagate verification and subKind for IR devices (bug #28)
Apr 20, 2026
bebc1d7
fix(cache): scope devices/status cache by active profile (bug #37)
Apr 20, 2026
db5a2cf
fix(cache): key in-memory hot cache by profile to prevent leak in mcp…
Apr 20, 2026
e42e7a3
fix(devices): restore --fields id and name aliases (bug #22)
Apr 20, 2026
2a51e89
fix(errors): reclassify API code 190 as device-internal-error (bug #27)
Apr 20, 2026
178e19a
fix(scenes): pre-validate sceneId in execute against scene list (bug …
Apr 20, 2026
419556e
fix(cache): accept --status and --list as shorthand for --key (bug #35)
Apr 20, 2026
0e6b6f3
fix(meta): enforce alias uniqueness across devices (bug #41)
Apr 20, 2026
5f1aa17
fix(history): mark --metric as requiredOption in aggregate (bug #42)
Apr 20, 2026
c9570fe
test(cache): cover --status/--list conflict paths (bug #35)
Apr 20, 2026
220af2e
fix(errors): map API code 3005 to command-not-supported (bug #29)
Apr 20, 2026
8b23345
fix(plan): clarify `plan validate` is structural-only (bug #32)
Apr 20, 2026
7cd2a69
docs(cache): clarify TTL uses JSON lastUpdated field, not mtime (bug …
Apr 20, 2026
b2a3658
fix(meta): surface devices meta in agent-bootstrap and capabilities (…
Apr 20, 2026
5890ddc
docs(history): describe .json vs .jsonl companion files (bug #43)
Apr 20, 2026
cb4bbc1
chore(release): 2.5.1
Apr 20, 2026
6ade9fd
test(mcp): drop hardcoded version literal
Apr 20, 2026
1671890
fix(flags): duration parser accepts d and w units (bug #54)
Apr 20, 2026
7ff291b
fix(name-resolver): surface all 6 strategies in help and agent-bootstrap
Apr 20, 2026
c2c8ab5
fix(mcp): reject empty query in search_catalog
Apr 20, 2026
dd40f4a
fix(batch): accept --idempotency-key as alias for --idempotency-key-p…
Apr 20, 2026
7474393
feat(batch): --skip-offline flag to short-circuit offline devices
Apr 20, 2026
fd01575
fix(devices): accept negative positional parameters for setBrightness…
Apr 20, 2026
ba68033
feat(watch,events): --for duration alias complementing --max
Apr 20, 2026
a261154
fix(events): emit __session_start envelope on mqtt-tail --json (bug #56)
Apr 20, 2026
7c50e50
fix(devices): accept ~substring and =/regex/ operators in --filter DS…
Apr 20, 2026
bc473b0
fix(output): route --json errors to stdout so pipelines can decode th…
Apr 20, 2026
5ca2188
fix(mcp): preflight deviceId in send_command dryRun (bug #SYS-3)
Apr 20, 2026
55c5d33
docs(2.5.1): fold round-3 fixes into CHANGELOG + README
Apr 20, 2026
d557357
fix(devices): route describe DeviceNotFoundError through emitJsonErro…
Apr 20, 2026
279de64
feat(filter)!: unify --filter DSL across list/batch/events (bug #39)
Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ CLAUDE.md

# Init transcript
2026-04-10-155920-command-messageinitcommand-message.txt
tmp/
197 changes: 197 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,203 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.5.1] - 2026-04-20

Round-2 + Round-3 smoke-test response: 24 bugs closed across three groups —
Round-2 correctness (13), Round-2 leftovers (3), and Round-3 contract & DX
(8). Sources: `switchbot-cli-v2.5.0-round2-report.md` and
`switchbot-cli-v2.5.0-round3-report.md`.

The release was cut initially against the Round-2 report; the Round-3 report
arrived shortly after and is folded into the same patch so consumers of
2.5.1 get the full fix set in one version bump. The two Round-3 🔴 items
(`#SYS-1`, `#SYS-3`) are contract bugs that break agent pipelines and could
not wait.

This version also contains one **breaking change** — the `--filter` grammar
is now unified across `devices list`, `devices batch`, and
`events tail` / `mqtt-tail`. `devices batch` and `events tail` keys that
used to require exact matches are now substrings. See
**Changed (BREAKING)** below for the migration.

### Changed (BREAKING)

- **`--filter` grammar unified across three surfaces** — `devices list`,
`devices batch`, and `events tail` / `mqtt-tail` now share one DSL:
`key=value` (case-insensitive substring; exact only for `category`),
`key~value` (explicit case-insensitive substring), and
`key=/pattern/` (case-insensitive regex; invalid regex returns a usage
error). Each command still exposes its own key set — see README
§"Filter expressions — per-command reference". (bug #39)
- **Breaking**: `devices batch --filter 'type=Bot'` previously required
an exact match and now treats `Bot` as a substring (matches `Bot Plus`
too). Pair `=` with a more specific value, or filter post-hoc, if
exact match was load-bearing.
- **Breaking**: `devices batch --filter 'type~=...'` (the `~=` spelling)
is removed. Use `~` instead: `type~Light`.
- **Breaking**: `events tail --filter 'deviceId=ABC'` is now a substring
match (previously exact).

### Fixed (correctness & safety)

- **`devices command --dry-run --json` no longer emits empty stdout** —
the single-device write path was hitting `handleError`'s silent
`DryRunSignal` exit before the JSON serializer ran. Now mirrors the MCP
`send_command {dryRun:true}` shape:
`{schemaVersion:"1.1", data:{dryRun:true, wouldSend:{deviceId,command,parameter,commandType}}}`.
Batch and plan dry-run paths were already correct. (bug #36)
- **MCP tool-call errors preserve structure** — `send_command` /
`describe_device` / `run_scene` were letting `ApiError`s escape to the
SDK's generic `createToolError`, collapsing `{code, subKind, transient,
hint, retryAfterMs, errorClass}` to a plain-text string. Errors now
return `structuredContent.error` alongside `isError:true` so agents can
branch on `subKind` instead of parsing English. Also narrowed the
`mcpError()` option types so `subKind` / `errorClass` are compile-time
checked. (bug #38)
- **`devices batch` propagates `verification` + `subKind` for IR devices** —
a batch over IR remotes was emitting zero unverifiability signal, the
exact contract 2.4.0 was released to establish. `succeeded[]` entries
now include `subKind:'ir-no-feedback'` and the verification object for
IR devices, plus `summary.unverifiableCount`. (bug #28)
- **Device & status cache scoped per profile** — `devices.json` and
`status.json` lived at a fixed disk path, so rotating credentials or
switching profiles served the *prior* session's inventory. Cache files
now live under `~/.switchbot/cache/<sha256(profile):8>/` when a profile
is active; unnamed/default profile keeps the legacy `~/.switchbot/`
path (backwards compatible). A follow-up fix also keys the in-memory
hot cache (`_listCache` / `_statusCache`) by profile so `mcp serve`
request-scoped profile switches do not leak either. (bug #37)
- **API code 190 reclassified `device-internal-error`** — 190 fires for
invalid deviceIds, unsupported parameters, AND non-device endpoints
like `webhook query` with no webhook configured. The `device-busy`
subKind and device-specific hint were misleading for webhook. Renamed
subKind + rewrote hint to cover all three causes. (bug #27)
- **API code 3005 mapped to `command-not-supported`** — 3005 "invalid
value" is the API's catch-all for model-specific command rejections
(e.g., Fan `lowSpeed/middleSpeed/highSpeed` on stock IR remotes that
only work under `--type customize`). Now returns a useful subKind + hint
pointing to `devices commands <type>` and `--type customize`. (bug #29)
- **`scenes execute` pre-validates sceneId** — `scenes execute <bogus>`
returned `ok:true` because the API does not validate sceneIds.
`scenes describe` already guarded against this via `scene_not_found` —
port the same check so agents do not silently burn quota. (bug #31)
- **`devices meta set --alias` enforces uniqueness** — nothing stopped
two devices from carrying the same alias; `--name <dup-alias>` behavior
was undefined. Reject duplicate aliases with a clear error naming the
existing holder; `--force` reassigns (clears the old holder's alias)
with a log line. (bug #41)

### Fixed (UX & docs)

- **`--fields id` / `--fields name` aliases restored on `devices list`** —
the 2.5.0 alias-map refactor dropped the short forms that 2.4.0
accepted, breaking scripts. `id → deviceId` is back alongside
`name → deviceName`. (bug #22)
- **`cache clear --status` and `--list` shorthand aliases** — the old
`--key status` form still works, but the shorter flags no longer
error with `unknown option`. Using them with `--key` or together
raises `UsageError`. (bug #35)
- **`history aggregate --metric` marked `requiredOption`** — help text
said `(default: [])` implying optional; the command actually required
at least one metric and threw a custom error. Now Commander enforces
it and `--help` says `required`. (bug #42)
- **`plan validate` help text clarifies scope** — now says "structural
only; does not verify device or scene existence" and points to
`plan run --dry-run` for semantic checks. (bug #32)
- **`cache` help text documents TTL behavior** — the cache TTL is computed
from the `lastUpdated` field *inside* the JSON, not file mtime.
Operators who `touch`ed cache files to force a refresh were surprised.
One-line note added to `cache show --help`. (bug #34)
- **`devices meta` surfaced in agent-bootstrap and capabilities** — the
local metadata system was completely undiscoverable in 2.5.0. `meta set
/ get / list / clear` now appear in `capabilities` with correct safety
tiers, and `agent-bootstrap`'s `quickReference` gains a `meta` entry.
(bug #40)
- **`~/.switchbot/device-history/<id>.json` companion file documented** —
the 100-entry ring buffer read by MCP `get_device_history` had no docs,
while only the append-only `.jsonl` was mentioned. `docs/agent-guide.md`
now describes both files and `__control.jsonl`. (bug #43)

### Fixed (Round 3 contract bugs — 🔴)

- **`--json` errors now emit on stdout instead of stderr** — piped
consumers (`cli --json ... | jq`) could not decode failure envelopes
because `handleError` wrote them to stderr. The JSON envelope
`{schemaVersion, error:{...}}` now lands on stdout for both success
and failure; TTY users still get a colored human-readable summary on
stderr, non-TTY invocations get silence on stderr. 15+ bespoke JSON
error sites across `batch`, `config`, `devices`, `expand`, `history`,
`mcp`, and `format` were consolidated through a new `emitJsonError`
helper. (bug #SYS-1)
- **MCP `send_command { dryRun:true }` validates deviceId against the
local cache** — dryRun previously accepted any string and echoed back
a plausible-looking preview, defeating the whole point of a
validation surface. Unknown IDs now return `subKind:'device-not-found'`
with a hint to run `list_devices` first. Happy path unchanged for
cached IDs. (bug #SYS-3)

### Fixed (Round 2 leftovers)

- **`devices batch --idempotency-key`** accepted as alias for
`--idempotency-key-prefix`. Still uses prefix semantics internally
(auto-appends `-<deviceId>` per step). (bug #30)

### Added (Round 2/3 features)

- **`devices batch --skip-offline`** (default off) skips devices whose
cached status is offline, with each skip recorded under
`summary.skipped` with `skippedReason:'offline'`. Reads the local
status cache only — no new API calls. Off by default preserves 2.5.0
behavior. (bug #33)
- **`--for <duration>` alias** on `devices watch`, `events tail`, and
`events mqtt-tail` — stops after elapsed time instead of tick/event
count. Accepts the same duration grammar as `--since` (ms/s/m/h/d/w).
When both `--for` and `--max` are set, the first limit to hit wins.
(bug #52)
- **Duration parser accepts `d` (days) and `w` (weeks)** in addition
to `ms/s/m/h`. Unsupported units like `1y` / `1month` now produce a
usage error that lists the supported unit set. (bug #54)
- **`events mqtt-tail --json` emits a `__session_start` envelope**
immediately on invocation (before the broker connect), so downstream
tools can distinguish "connecting" from "never connected" and get an
eventId to correlate with subsequent `__connect` / `__disconnect`
events. (bug #56)

### Polish (Round 3 DX)

- **`--name-strategy` help + `agent-bootstrap` list all six
strategies** — `exact`, `prefix`, `substring`, `fuzzy`, `first`,
`require-unique`. `ALL_STRATEGIES` in `name-resolver.ts` is the
single source of truth; help text is generated from it. (bug #51)
- **MCP `search_catalog` rejects empty queries** with a usage error
pointing to `list_catalog_types` for enumeration. Silent
"return everything" behavior was surprising and agent-hostile.
(bug #57)
- **Negative positional parameters reach the validation layer** —
`setBrightness -1` was being swallowed by Commander as "unknown
option `-1`". `devices command` now uses `.passThroughOptions()` so
negative numeric positionals are forwarded to the command-specific
validator, where they can be accepted or range-rejected as
appropriate. (bug #53)

### Not included (response to reports)

- **Report bug #19 (MCP strict schema not enforced) — false positive.**
All 11 MCP tools already have `.strict()` on their Zod input schemas
and the SDK enforces it via `safeParseAsync` → JSON-RPC `-32602`.
Could not reproduce the reported behavior; the existing test suite
exercises the full JSON-RPC path.
- **Deferred to 2.6.0:**
- Report bug #58 (parallel `devices status` outlier) — needs
profiling to separate CLI-side latency from API-side, and the fix
likely involves a concurrency knob rather than a single flip.
- Report bug #55 (`devices watch --json` rewording) — already works
via the global `--json` flag; pure doc rewording scheduled with
other doc sweeps.
- MCP / CLI naming alignment (`live` vs `includeStatus`, `metric` vs
`metrics`) flagged in Round-3 §4.
- `devices meta import/export` (Round-2 #40 follow-up).

## [2.5.0] - 2026-04-20

### Added
Expand Down
49 changes: 45 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ switchbot devices list --filter category=physical
switchbot devices list --filter type=Bot
switchbot devices list --filter name=living,category=physical

# Filter operators: = (substring; exact for `category`), ~ (substring),
# =/regex/ (case-insensitive regex). Clauses are AND-ed.
switchbot devices list --filter 'name~living'
switchbot devices list --filter 'type=/Hub.*/'
switchbot devices list --filter 'name~office,type=/Bulb|Strip/'

# Filter by family / room (family & room info requires the 'src: OpenClaw'
# header, which this CLI sends on every request)
switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")'
Expand Down Expand Up @@ -255,6 +261,21 @@ switchbot devices commands "Smart Lock"
switchbot devices commands curtain # Case-insensitive, substring match
```

#### Filter expressions — per-command reference

Three commands accept `--filter`. They share one three-operator grammar,
but each exposes its own key set:

| Command | Operators | Supported keys |
|-------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------|
| `devices list` | `=` (substring; **exact** for `category`), `~` (substring), `=/regex/` (case-insensitive regex) | `type`, `name`, `category`, `room` |
| `devices batch` | same | `type`, `family`, `room`, `category` |
| `events tail` / `events mqtt-tail` | same (tail only; mqtt-tail uses `--topic` instead) | `deviceId`, `type` |

Clauses are comma-separated and AND-ed. No OR across clauses — use regex
alternation (`=/A|B/`) for that. `category` is the one key that stays exact
under `=` to preserve `category=physical` / `category=ir` semantics.

#### Parameter formats

`parameter` is optional — omit it for commands like `turnOn`/`turnOff` (auto-defaults to `"default"`).
Expand All @@ -280,6 +301,8 @@ Generic parameter shapes (which one applies is decided by the device — see the

Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), and `setMode` (Relay Switch) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.

Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option.

For the complete per-device command reference, see the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands).

#### `devices expand` — named flags for packed parameters
Expand Down Expand Up @@ -333,7 +356,7 @@ Stores local annotations (alias, hidden flag, notes) in `~/.switchbot/device-met
```bash
# Send the same command to every device matching a filter
switchbot devices batch turnOff --filter 'type=Bot'
switchbot devices batch setBrightness 50 --filter 'type~=Light,family=Living'
switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living'

# Explicit device IDs (comma-separated)
switchbot devices batch turnOn --ids ID1,ID2,ID3
Expand All @@ -343,9 +366,18 @@ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch

# Destructive commands require --yes
switchbot devices batch unlock --filter 'type=Smart Lock' --yes

# Skip devices whose cached status is offline (default: off)
switchbot devices batch turnOn --ids ID1,ID2 --skip-offline

# --idempotency-key is an alias for --idempotency-key-prefix; both append -<deviceId>
switchbot devices batch turnOn --ids ID1,ID2 --idempotency-key morning-lights
```

Sends the same command to many devices in one run. Uses the same `--filter` expressions as `devices list`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.
Sends the same command to many devices in one run. Filter grammar matches `devices list` (`=` substring, `~` substring, `=/regex/` regex — clauses AND-ed); supported keys here are `type`, `family`, `room`, `category`. Destructive commands (Smart Lock unlock, Garage Door Opener, etc.) require `--yes` to prevent accidents.

`--skip-offline` reads from the local status cache only (no new API calls);
skipped devices appear under `summary.skipped` with `skippedReason:'offline'`.

### `scenes` — run manual scenes

Expand Down Expand Up @@ -390,6 +422,9 @@ switchbot events tail --filter deviceId=ABC123
# Stop after 5 matching events
switchbot events tail --filter 'type=WoMeter' --max 5

# Stop after 10 minutes regardless of event count
switchbot events tail --for 10m

# Custom port / path
switchbot events tail --port 8080 --path /hook --json
```
Expand All @@ -401,7 +436,7 @@ Output (one JSON line per matched event):
{ "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true }
```

Filter keys: `deviceId=<id>`, `type=<deviceType>` (comma-separated for AND logic).
Filter keys: `deviceId`, `type`. Operators: `=` (substring), `~` (substring), `=/regex/` (case-insensitive regex). Clauses comma-separated and AND-ed.

#### `events mqtt-tail` — real-time MQTT stream

Expand All @@ -414,6 +449,9 @@ switchbot events mqtt-tail --topic 'switchbot/#'

# Stop after 10 events
switchbot events mqtt-tail --max 10 --json

# Stop after a fixed duration (emits __session_start under --json before connect)
switchbot events mqtt-tail --for 30s --json
```

Connects to the SwitchBot MQTT service automatically using the same credentials configured for the REST API (`SWITCHBOT_TOKEN` + `SWITCHBOT_SECRET`). No additional MQTT configuration is required — the client certificates are provisioned on first use.
Expand Down Expand Up @@ -514,9 +552,12 @@ switchbot devices watch <deviceId>

# Custom interval; emit every tick even when nothing changed
switchbot devices watch <deviceId> --interval 10s --include-unchanged --json

# Time-bounded: stop after 5 minutes instead of a fixed tick count
switchbot devices watch <deviceId> --for 5m
```

Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks.
Output is a JSONL stream of status-change events (with `--json`) or a refreshed table. Use `--max <n>` to stop after N ticks, or `--for <duration>` to stop after an elapsed wall-clock window (e.g. `30s`, `1h`, `2d`). When both are set, whichever limit trips first wins.

### `mcp` — Model Context Protocol server

Expand Down
12 changes: 12 additions & 0 deletions docs/agent-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,18 @@ Reads `~/.switchbot/device-history/<deviceId>.json` written by `events mqtt-tail

**Workflow**: run `switchbot events mqtt-tail` in the background (e.g. with pm2) to keep the history files fresh; then call `get_device_history` from any MCP session without consuming REST quota.

#### Device-history directory layout

After `events mqtt-tail` runs on a device, `~/.switchbot/device-history/` contains up to three companion files per device:

| File | Description |
|------|-------------|
| `<deviceId>.jsonl` | Append-only, authoritative event log. Source of truth for `history range` and `history aggregate`. Rotated at ~50 MB (up to 3 segments). |
| `<deviceId>.json` | Latest 100-entry ring buffer. Written on every MQTT event. Read by MCP `get_device_history` for fast, zero-quota retrieval. |
| `__control.jsonl` | MQTT connection lifecycle events (heartbeat, connect, disconnect). Not a device log; used for diagnostics. |

The `.json` file is **not** the source of truth for historical queries — use `.jsonl` (via `history range` or `history aggregate`) when you need a complete, time-bounded record. The `.json` file is optimised for "what is the latest state?" lookups.

### MCP resource: `switchbot://events`

Read-only snapshot of recent MQTT shadow-update events from the ring buffer. Returns `{state, count, events[]}`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading