Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,46 @@ 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.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
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.0",
"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
33 changes: 25 additions & 8 deletions src/commands/agent-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { Command } from 'commander';
import { printJson } from '../utils/output.js';
import { loadCache } from '../devices/cache.js';
import { getEffectiveCatalog } from '../devices/catalog.js';
import {
getEffectiveCatalog,
deriveSafetyTier,
CATALOG_SCHEMA_VERSION,
} from '../devices/catalog.js';
import { readProfileMeta } from '../config.js';
import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
import { ALL_STRATEGIES } from '../utils/name-resolver.js';
Expand All @@ -10,6 +14,15 @@ import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { version: pkgVersion } = require('../../package.json') as { version: string };

/**
* Schema version of the agent-bootstrap payload. Must stay in lockstep
* with the catalog schema β€” bootstrap consumers (AI agents) reason about
* catalog-derived fields (safetyTier, destructive flag), so a drift
* between the two would silently break their assumptions. `doctor`
* fails the `catalog-schema` check when these differ.
*/
export const AGENT_BOOTSTRAP_SCHEMA_VERSION = CATALOG_SCHEMA_VERSION;

const IDENTITY = {
product: 'SwitchBot',
domain: 'IoT smart home device control',
Expand Down Expand Up @@ -107,18 +120,22 @@ Examples:
category: e.category,
role: e.role ?? null,
readOnly: e.readOnly ?? false,
commands: e.commands.map((c) => ({
command: c.command,
parameter: c.parameter,
destructive: Boolean(c.destructive),
idempotent: Boolean(c.idempotent),
})),
commands: e.commands.map((c) => {
const tier = deriveSafetyTier(c, e);
return {
command: c.command,
parameter: c.parameter,
safetyTier: tier,
destructive: tier === 'destructive',
idempotent: Boolean(c.idempotent),
};
}),
statusFields: e.statusFields ?? [],
};
});

const payload: Record<string, unknown> = {
schemaVersion: '1.0',
schemaVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
cliVersion: pkgVersion,
identity: IDENTITY,
Expand Down
Loading
Loading