Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8d7fb78
feat: v2.7 AI-first maturity — filter fields, help JSON, destructive …
Apr 21, 2026
2dbcb6a
feat(field-aliases): dispatch + expand to ~55 alias groups (P1)
Apr 21, 2026
b75be1f
refactor(catalog): replace destructive:boolean with safetyTier 5-tier…
Apr 21, 2026
1d5d197
test(help-json): contract coverage for all 16 top-level commands
Apr 21, 2026
7b59ed3
refactor(mcp): complete tool schemas — describe all inputs, type outputs
Apr 21, 2026
ca7c23d
refactor(errors): route all errors through exitWithError / handleErro…
Apr 21, 2026
9c5a804
feat(events): unified envelope across tail / mqtt-tail (P6)
Apr 21, 2026
ac682cd
feat(streaming): schemaVersion header for NDJSON streams + docs (P7)
Apr 21, 2026
0270325
fix(quota): record all API call attempts, not only successes (P8)
Apr 21, 2026
6173da3
feat(doctor): quota headroom + catalog-schema + audit checks (P9)
Apr 21, 2026
ff3accc
feat(doctor): MQTT live-probe + MCP dry-run + --section/--list/--fix …
Apr 21, 2026
4a48f07
feat(catalog): statusQueries list powering safetyTier 'read' tier (P11)
Apr 21, 2026
4ec2a63
refactor(batch): rename --plan to --emit-plan, deprecate old flag (P12)
Apr 21, 2026
022ec75
feat(field-aliases): final Phase 4 sweep (~98% coverage)
Apr 21, 2026
5690bca
feat(resources): scenes/webhooks/keys metadata catalog (P15)
Apr 21, 2026
28bfc29
chore: bump to 2.7.0 + sync lockfile + CHANGELOG (P16)
Apr 21, 2026
7257784
refactor(help): centralize product IDENTITY and make --help AI-discov…
Apr 21, 2026
2e8239a
fix(schema): drop resources block under --compact to fit CI size budget
Apr 21, 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
67 changes: 67 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,73 @@ 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.7.2] - 2026-04-21

Patch release — CI size-budget fix.

### Fixed

- **`schema export --compact`** — dropped the `resources` block from compact output. In v2.7.0 the resources catalog (scenes / webhooks / keys, ~12 KB) was added to the schema payload unconditionally, which pushed `schema export --compact --used` past the 15 KB agent-prompt budget enforced by CI. The `resources` block is still emitted under the full (non-`--compact`) output, and is always available via `capabilities --json`, which is the canonical source for CLI resource metadata. No behaviour change for `capabilities --json` consumers.

## [2.7.1] - 2026-04-21

AI-discoverability patch. Top-level `--help` / `--help --json` and every
subcommand description now lead with the SwitchBot product category
(smart home: lights, locks, curtains, sensors, plugs, IR appliances) so
AI agents reading help text can identify scope without parsing the
catalog. Identity is consolidated into a single module to prevent drift.

### Changed

- **Top-level `switchbot --help`** — program description rewritten to "SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time events, and integrate AI agents via MCP." (previously the terse "Command-line tool for SwitchBot API v1.1"). Both human and AI scanners now learn the product category on the first line.
- **`switchbot --help --json` (root)** — now carries top-level `product`, `domain`, `vendor`, `apiVersion`, `apiDocs`, and `productCategories[]` fields for programmatic discovery. Subcommand `--help --json` output is unchanged (identity is root-only to keep per-command payloads tight).
- **Subcommand descriptions** — `catalog`, `schema`, `history`, `plan`, `doctor`, `capabilities` now explicitly mention "SwitchBot" so each command self-describes in `--help` (the other 10 top-level commands already mentioned it).
- **README intro** — rewritten to lead with the product category ("SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances …") instead of the API version.

### Refactored

- **Shared IDENTITY module** — extracted the product-identity constant to `src/commands/identity.ts`; `capabilities.ts`, `agent-bootstrap.ts`, and `utils/help-json.ts` now import from a single source of truth to prevent field drift. The canonical IDENTITY adds `productCategories: string[]` (8 category keywords AI agents can scan) and clarifies `constraints.transport = "Cloud API v1.1 (HTTPS)"` — the CLI does **not** drive BLE radios directly; BLE-only devices are reached through a SwitchBot Hub, which the Cloud API handles transparently. `agent-bootstrap --json` gains additive identity fields (`apiDocs`, `deviceCategories`, `productCategories`, `agentGuide`) via the shared module; no fields removed.

## [2.7.0] - 2026-04-21

AI-first maturity release. Broader field-alias coverage, richer capability
metadata, and agent-discoverable resource surfaces (scenes, webhooks, keys).

### Added

- **Field aliases** — registry expanded from ~10 to ~51 canonical keys (~98% coverage of catalog `statusFields` + webhook payload fields), dispatched through `devices status`, `devices watch`, and `--fields` parsers. Phase 4 sweep adds ultra-niche sensor/webhook aliases: `waterLeakDetect`, `pressure`, `moveCount`, `errorCode`, `buttonName`, `pressedAt`, `deviceMac`, `detectionState`.
- **safetyTier enum (5 tiers)** — catalog commands now carry `safetyTier: 'read' | 'mutation' | 'ir-fire-forget' | 'destructive' | 'maintenance'`; replaces the legacy `destructive: boolean` flag.
- **`DeviceCatalogEntry.statusQueries`** — read-tier catalog entries exposing queryable status fields; derived from existing `statusFields` plus a curated `STATUS_FIELD_DESCRIPTIONS` map. Powers `safetyTier: 'read'` and lights up `capabilities.catalog.readOnlyQueryCount`.
- **`capabilities.resources`** — new top-level `resources` block in `capabilities --json` and `schema export`, exposing scenes (list/execute/describe), webhooks (4 endpoints + 15 event specs + constraints), and keypad keys (4 types: permanent/timeLimit/disposable/urgent). Each endpoint/event declares its safety tier so agents can plan without trial-and-error.
- **Multi-format output** — `--format=yaml` and `--format=tsv` for all non-streaming commands (devices list, scenes list, catalog, etc.); `id` / `markdown` formats preserved. `--json` remains the alias for `--format=json`.
- **doctor upgrades** — new `--section`, `--list`, `--fix`, `--yes`, `--probe` flags; new checks `catalog-schema`, `audit`, `mcp` (dry-run — instantiates MCP server and counts registered tools), plus live MQTT probe (guarded by `--probe`, 5 s timeout).
- **Streaming JSON contract** — every streaming command (watch / events tail / events mqtt-tail) now emits a `{ schemaVersion, stream: true, eventKind, cadence }` header as its first NDJSON line; documented in `docs/json-contract.md`.
- **Events envelope** — unified `{ schemaVersion, t, source, deviceId, topic, type, payload }` shape across `events tail` and `events mqtt-tail`.
- **MCP tool schema completeness** — every tool input schema now carries `.describe()` annotations; new test suite enforces this.
- **Help-JSON contract test** — table-driven coverage for all 16 top-level commands.
- **batch `--emit-plan`** — new canonical flag alias for the deprecated `--plan`.

### Changed

- **Error envelope** — all error paths route through `exitWithError()` / `handleError()`; `--json` failure output always carries `schemaVersion` + structured `error` object.
- **Quota accounting** — requests are recorded on attempt (request interceptor) instead of on success, so timeouts / 4xx / 5xx count against daily quota.
- **`--json` vs `--format=json`** — both paths go through the same formatter; `--json` is now documented as the alias.

### Deprecated

- `destructive: boolean` on catalog entries — derived from `safetyTier === 'destructive'`. Removed in v3.0.
- `DeviceCatalogEntry.statusFields` — superseded by `statusQueries`. Removed in v3.0.
- `batch --plan` — renamed to `--emit-plan`. Old flag still works but prints a deprecation warning to stderr. Removed in v3.0.
- Events legacy fields `body` / `remote` on `events tail` — superseded by the unified envelope. Removed in v3.0.

### Reserved

- `safetyTier: 'maintenance'` — enum value accepted by the type system but no catalog entry uses it today. Reserved for future SwitchBot API endpoints (factoryReset, firmwareUpdate, deepCalibrate).

### Fixed

- Quota counter no longer under-counts requests that fail at the transport or server layer.

## [2.6.4] - 2026-04-21

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
[![node](https://img.shields.io/node/v/@switchbot/openapi-cli.svg)](https://nodejs.org)
[![CI](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml)

Command-line interface for the [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI).
List devices, query live status, send control commands, run scenes, receive real-time events, and connect AI agents via the built-in MCP server — all from your terminal or shell scripts.
**SwitchBot** smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI).
Run scenes, stream real-time events over MQTT, and plug AI agents into your home via the built-in MCP server — all from your terminal or shell scripts.

- **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli)
- **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli)
Expand Down
189 changes: 189 additions & 0 deletions docs/json-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
# JSON Output Contract

`switchbot-cli` emits machine-readable output on **stdout** whenever you pass
`--json` (or the `--format=json` alias once P13 lands). Stderr is reserved for
human-facing progress / warnings and is never part of the contract.

There are two output shapes. You pick the right parser by the shape of the
**first line** emitted on stdout.

---

## 1. Single-object / array commands (non-streaming)

Most commands — `devices list`, `devices status`, `devices describe`,
`capabilities`, `schema export`, `doctor`, `catalog show`, `history list`,
`scenes list`, `webhook query`, etc. — emit **exactly one** JSON envelope on
stdout.

### Success envelope

```json
{
"schemaVersion": "1.1",
"data": <command-specific payload>
}
```

- `schemaVersion` tracks the envelope shape, not the inner payload shape.
The envelope version only bumps when `data` moves or is renamed; inner
payload changes get called out in `CHANGELOG.md` on a per-command basis.
- `data` is always present on success (never `null`).

### Error envelope

```json
{
"schemaVersion": "1.1",
"error": {
"code": 2,
"kind": "usage" | "guard" | "api" | "runtime",
"message": "human-readable description",
"hint": "optional remediation string",
"context": { "optional, command-specific": true }
}
}
```

- Both success and error envelopes are written to **stdout** so a single
`cli --json ... | jq` pipe can decode either shape (SYS-1 contract).
- `code` is the process exit code. `2` = usage / guard, `1` = runtime / api.
- Additional fields may appear on specific error classes
(`retryable`, `retryAfterMs`, `transient`, `subKind`, `errorClass`).

---

## 2. Streaming / NDJSON commands

Three commands emit one JSON document per line (NDJSON) instead of a single
envelope:

| Command | `eventKind` | `cadence` |
|---------------------|-------------|-----------|
| `devices watch` | `tick` | `poll` |
| `events tail` | `event` | `push` |
| `events mqtt-tail` | `event` | `push` |

### Stream header (always the first line under `--json`)

```json
{ "schemaVersion": "1", "stream": true, "eventKind": "tick" | "event", "cadence": "poll" | "push" }
```

- **Must always be the first line** on stdout under `--json`. Consumers
should read one line, parse, and key on `{ "stream": true }` to confirm
they are reading from a streaming command.
- `eventKind` picks the downstream parser. `tick` → `devices watch` shape
with `{ t, tick, deviceId, changed, ... }`. `event` → unified event
envelope (see below).
- `cadence`:
- `poll` — the CLI drives timing. One line per `--interval`.
- `push` — broker/webhook drives timing. Quiet gaps are normal.

### Event envelope (subsequent lines on `events tail` / `events mqtt-tail`)

```json
{
"schemaVersion": "1",
"source": "webhook" | "mqtt",
"kind": "event" | "control",
"t": "2026-04-21T14:23:45.012Z",
"eventId": "uuid-v4-or-null",
"deviceId": "BOT1" | null,
"topic": "/webhook" | "$aws/things/.../shadow/update/accepted",
"payload": { /* source-specific */ },
"matchedKeys": ["deviceId", "type"]
}
```

- `source` and `kind` together tell a consumer how to treat the record.
Control events (`kind: "control"`) carry a `controlKind` like
`"connect"`, `"reconnect"`, `"disconnect"`, `"heartbeat"`.
- `matchedKeys` is only populated on webhook events when `--filter` was
supplied — it lists which filter clauses hit.
- Legacy fields (`body`, `remote`, `path`, `matched`, `type`, `at`) are
still emitted alongside the unified fields for one minor window. They
are **deprecated** and will be removed in the next major release; new
consumers should read only the unified fields above.

### Tick envelope (subsequent lines on `devices watch`)

```json
{
"schemaVersion": "1.1",
"data": {
"t": "2026-04-21T14:23:45.012Z",
"tick": 1,
"deviceId": "BOT1",
"type": "Bot",
"changed": { "power": { "from": null, "to": "on" } }
}
}
```

Watch records reuse the single-object envelope (`{ schemaVersion, data }`)
— only the header uses the lean streaming shape. That keeps the existing
watch consumers working: they only need to add a filter that skips the
first header line.

### Errors from a streaming command

If a streaming command hits a fatal error mid-stream, it emits the
**error envelope** (section 1) on stdout and exits non-zero. Consumers
should be prepared to see either `{ stream: true }` or `{ error: ... }`
on any line.

---

## 3. Consumer patterns

**Route by shape** on line 1:

```bash
# generic: peek at line 1, pick parser
first=$(head -n 1)
if echo "$first" | jq -e '.stream == true' >/dev/null; then
# streaming — subsequent lines are event envelopes
while IFS= read -r line; do
echo "$line" | jq 'select(.kind == "event")'
done
else
# single-object / array — $first already has the whole payload
echo "$first" | jq '.data'
fi
```

**Skip the stream header** if you only want events:

```bash
switchbot events mqtt-tail --json | jq -c 'select(.stream != true)'
```

**Detect the error envelope** from any command:

```bash
switchbot devices status BOT1 --json | jq -e '.error' && exit 1
```

---

## 4. Versioning

- The non-streaming envelope is versioned as `schemaVersion: "1.1"`.
- The streaming header and event envelope are versioned as
`schemaVersion: "1"`.
- The two axes are deliberately separate: adding a field inside `data`
does **not** bump the envelope, but renaming / removing `data` would.
- Breaking changes land on a major release. Additive fields land on a
minor release and are listed under `### Added` in `CHANGELOG.md`.

---

## 5. What this contract does NOT cover

- Human-readable (`--format=table` or default) output — may change at any
time.
- Stderr output — progress strings, deprecation warnings, TTY hints. Do
not parse stderr.
- In-file history records under `~/.switchbot/device-history/` — see
`docs/schema-versioning.md`.
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.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "2.6.4",
"version": "2.7.2",
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
"keywords": [
"switchbot",
Expand Down
26 changes: 14 additions & 12 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ export function createClient(): AxiosInstance {
throw new DryRunSignal(method, url);
}

// P8: record the quota attempt BEFORE the request is dispatched so
// failures (timeouts / DNS errors / 5xx / aborted) also count. Only
// pre-flight refusals (daily-cap, --dry-run) above skip recording
// since they never touch the network. Retries re-enter this
// interceptor and record again, which matches the SwitchBot API
// billing model (every dispatched HTTP request consumes quota).
if (quotaEnabled) {
recordRequest(method, url);
}

return config;
});

Expand All @@ -112,11 +122,6 @@ export function createClient(): AxiosInstance {
if (verbose) {
process.stderr.write(chalk.grey(`[verbose] ${response.status} ${response.statusText}\n`));
}
if (quotaEnabled && response.config) {
const method = (response.config.method ?? 'get').toUpperCase();
const url = `${response.config.baseURL ?? ''}${response.config.url ?? ''}`;
recordRequest(method, url);
}
const data = response.data as { statusCode?: number; message?: string };
if (data.statusCode !== undefined && data.statusCode !== 100) {
const msg =
Expand Down Expand Up @@ -210,13 +215,10 @@ export function createClient(): AxiosInstance {
}
}

// Record exhausted/non-retryable HTTP responses too — they count
// against the daily quota.
if (quotaEnabled && error.response && config) {
const method = (config.method ?? 'get').toUpperCase();
const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
recordRequest(method, url);
}
// P8: quota already recorded in the request interceptor before
// dispatch — no extra bookkeeping needed here on the error path.
// Timeouts, DNS failures, 5xx, and exhausted retries all counted
// when the attempt was first made.

if (status === 401) {
throw new ApiError(
Expand Down
Loading
Loading