diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b3c6f9..b6a818f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **Daemon start failed in bundled builds** (BUG-001): CLI entry path resolution navigated above the dist/ directory when running from the single-file bundle. Now correctly detects the bundled scenario. +- **`rules run` exited 0 when `automation.enabled` was false** (BUG-002): daemon interpreted this as success. Now exits 1 with a clear message. +- **Unknown subcommands exited 0** (BUG-005/BUG-008): `cache list`, `history list`, and other invalid subcommand inputs triggered Commander help display and exited 0. Now exits 2 (usage error). +- **`mcp tools --json` omitted description and inputSchema** (BUG-007): tool directory only listed names. Now includes full tool metadata. +- **Pino logger wrote to stdout** (BUG-009): redirected to stderr so it doesn't corrupt JSON/MCP output. + +### Changed (Breaking) + +- **`schemaVersion` bumped from `1.1` to `1.2`**: all `--json` responses now carry `schemaVersion: "1.2"`. Consumers that pin on the exact string must update their check. Parsers that only read `data`/`error` are unaffected. +- **`catalog show --json`**: `data` is now always an array (single-entry array when filtering by type). Previously was a bare object for single-type queries. +- **`devices commands --json`**: same change — `data` is always an array. +- **`_fetchedAt` renamed to `fetchedAt`**: removed underscore prefix from the CLI-added timestamp field in `devices status` JSON output. +- **`rules run --json` when `automation.enabled` is false**: previously emitted `{data: {kind:"control", controlKind:"disabled"}}` (success envelope) with exit 1. Now emits `{error: {code:1, kind:"runtime", message:"..."}}` (error envelope) — consistent with the JSON protocol. + +### Added + +- **`devices expand` supports lighting commands**: `setBrightness` (`--brightness`), `setColor` (`--color`), and `setColorTemperature` (`--color-temp`) flags now expand for Color Bulb, Strip Light, Ceiling Light, and similar devices. + ## [3.4.0] - 2026-05-07 ### Added diff --git a/README.md b/README.md index 1a9243d..0568c91 100644 --- a/README.md +++ b/README.md @@ -45,44 +45,14 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — ## Table of contents -- [Features](#features) -- [Requirements](#requirements) -- [Installation](#installation) +- [Features](#features) · [Requirements](#requirements) · [Installation](#installation) - [Quick start](#quick-start) - [Credentials](#credentials) -- [Policy](#policy) +- [Policy](#policy) · [Rules engine](#rules-engine) - [Global options](#global-options) -- [Commands](#commands) - - [`config`](#config--credential-management) - - [`devices`](#devices--list-status-control) - - [`devices batch`](#devices-batch--bulk-commands) - - [`devices watch`](#devices-watch--poll-status) - - [`scenes`](#scenes--run-manual-scenes) - - [`webhook`](#webhook--receive-device-events-over-http) - - [`events`](#events--receive-device-events) - - [`status-sync`](#status-sync--mqttopenclaw-bridge) - - [`plan`](#plan--declarative-batch-operations) - - [`mcp`](#mcp--model-context-protocol-server) - - [`doctor`](#doctor--self-check) - - [`health`](#health--runtime-health-report) - - [`upgrade-check`](#upgrade-check--version-check) - - [`quota`](#quota--api-request-counter) - - [`history`](#history--audit-log) - - [`catalog`](#catalog--device-type-catalog) - - [`schema`](#schema--export-catalog-as-json) - - [`capabilities`](#capabilities--cli-manifest) - - [`cache`](#cache--inspect-and-clear-local-cache) - - [`policy`](#policy--validate-scaffold-and-migrate-policyyaml) - - [`daemon`](#daemon--background-rules-engine-process) - - [`completion`](#completion--shell-tab-completion) -- [Output modes](#output-modes) - - [Cache](#cache) -- [Exit codes & error codes](#exit-codes--error-codes) -- [Environment variables](#environment-variables) -- [Scripting examples](#scripting-examples) -- [Development](#development) -- [License](#license) -- [References](#references) +- [Commands](#commands): [config](#config--credential-management) · [devices](#devices--list-status-control) · [scenes](#scenes--run-manual-scenes) · [webhook](#webhook--receive-device-events-over-http) · [events](#events--receive-device-events) · [status-sync](#status-sync--mqttopenclaw-bridge) · [daemon](#daemon--background-rules-engine-process) · [plan](#plan--declarative-batch-operations) · [mcp](#mcp--model-context-protocol-server) · [doctor](#doctor--self-check) · [health](#health--runtime-health-report) · [upgrade-check](#upgrade-check--version-check) · [quota](#quota--api-request-counter) · [history](#history--audit-log) · [catalog](#catalog--device-type-catalog) · [schema](#schema--export-catalog-as-json) · [capabilities](#capabilities--cli-manifest) · [cache](#cache--inspect-and-clear-local-cache) · [policy cmd](#policy--validate-scaffold-and-migrate-policyyaml) · [completion](#completion--shell-tab-completion) +- [Output modes](#output-modes) · [Cache](#cache) · [Exit codes](#exit-codes--error-codes) · [Environment variables](#environment-variables) +- [Scripting examples](#scripting-examples) · [Development](#development) · [License](#license) --- @@ -93,7 +63,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI - 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 2204 Vitest tests, mocked axios, zero network in CI +- 🧪 **Fully tested** — 2225 Vitest tests, mocked axios, zero network in CI - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell ## Requirements @@ -296,110 +266,44 @@ then: template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' ``` -**LLM condition** — add an AI judgement step before actions fire. The engine calls the -configured LLM provider, passes the prompt plus recent event context, and gates execution -on the model's yes/no answer: +**LLM condition** — add an AI judgement step before actions fire: ```yaml conditions: - llm: prompt: "Is the temperature above normal comfort range?" provider: auto # auto | openai | anthropic - cache_ttl: 5m # skip redundant calls for identical context + cache_ttl: 5m budget: max_calls_per_hour: 20 on_error: pass # fail | pass | skip ``` -Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` (provider `auto` tries Anthropic first). -`rules lint` flags misconfigured LLM conditions (no provider key, cache TTL too high for -the trigger frequency, budget zero). Evaluation decisions are recorded in the trace log. +Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. `rules lint` flags misconfigured LLM conditions. -**Decision trace** — enable `automation.audit.evaluate_trace` in `policy.yaml` to record -every evaluation decision (why a rule fired or was blocked): - -```yaml -automation: - audit: - evaluate_trace: sampled # full | sampled | off (default: sampled) - evaluate_retention_days: 7 -``` +**Decision trace** — set `automation.audit.evaluate_trace: sampled` (or `full`) in `policy.yaml` to record every evaluation decision. ```bash -# 1. Author rules under `automation.rules`. See examples/policies/automation.yaml -# for a walkthrough covering the three trigger sources. - -# 2. Static-check before running. -switchbot rules lint # exit 0 valid, 1 error -switchbot rules list --json | jq . # structured summary - -# 3. Inspect a single rule in full detail (trigger, conditions, actions, -# cooldown, hysteresis, maxFiringsPerHour, suppressIfAlreadyDesired, last fired). -switchbot rules explain "motion on" -switchbot rules explain "motion on" --json - -# 4. Run the engine. --dry-run overrides every rule into audit-only mode; -# --max-firings bounds a demo session. -switchbot rules run --dry-run --max-firings 5 - -# 5. Edit policy.yaml in another shell, then hot-reload without restart. -switchbot daemon reload # managed daemon reload - -# 6. Review recorded fires. -switchbot rules tail --follow # stream rule-* audit lines -switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors -switchbot rules summary # aggregate fires/errors per rule (24h window) -switchbot rules last-fired -n 20 # 20 most recent fire entries +switchbot rules lint # static check: exit 0 valid, 1 error +switchbot rules list --json | jq . # structured rule summary +switchbot rules explain "motion on" # trigger, conditions, actions, last fired +switchbot rules run --dry-run --max-firings 5 # run engine; --dry-run = audit only +switchbot daemon reload # hot-reload policy without restart -# 7. Conflict and health analysis. -switchbot rules conflicts # opposing actions, high-frequency MQTT, - # destructive commands, quiet-hours gaps -switchbot rules doctor --json # lint + conflicts combined; exit 0 when clean +switchbot rules tail --follow # stream rule-* audit lines +switchbot rules replay --since 1h --json # per-rule fires/dries/throttled/errors +switchbot rules summary # aggregate fires/errors (24h) +switchbot rules conflicts # opposing actions, destructive cmds, quiet-hours gaps +switchbot rules doctor --json # lint + conflicts; exit 0 when clean -# 8. Scaffold a new rule from natural language (heuristic or LLM-backed). switchbot rules suggest --intent "turn off AC at 11pm" -switchbot rules suggest --intent "if door opens and temp below 20 turn on heater" \ - --llm auto # routes complex intents to LLM automatically -switchbot rules suggest --intent "..." --llm openai # explicit backend -# Set OPENAI_API_KEY or ANTHROPIC_API_KEY; auto mode falls back to heuristic on failure - -# 9. Explain why a specific evaluation fired or was blocked (requires evaluate_trace). -switchbot rules trace-explain --rule "motion on" --last -switchbot rules trace-explain --rule "motion on" --since 1h --json -switchbot rules trace-explain # single evaluation by ID - -# 10. Simulate a rule against historical events without running the engine. -switchbot rules simulate "motion on" # replay last 24h from audit log -switchbot rules simulate "motion on" --since 7d --json -switchbot rules simulate policy.yaml --rule "night AC" --against events.jsonl -``` - -`rules suggest` enforces several guardrails on LLM output so a model can't quietly arm -something unsafe: - -- **`dry_run` is forced to `true`** on every LLM-generated rule. Review the output and - flip it yourself before running the engine without `--dry-run`. -- **Explicit overrides always win.** If you pass `--trigger`, the LLM's answer must match; - a mismatch fails fast. Within the same trigger, mismatched `--event` / `--schedule` / - `--days` / `--webhook-path` are rewritten to your value with a warning. -- **`--llm` is enum-validated at the CLI** (`auto | openai | anthropic`) — junk values - exit non-zero instead of falling through. -- **Notify URLs must be `http://` or `https://`.** `rules lint` and the runtime both - reject `file://`, `ftp://`, etc., so a generated webhook can't smuggle in a non-HTTP - scheme. - -When `quiet_hours` is configured in `policy.yaml`, `rules conflicts` additionally flags event-driven (MQTT / webhook) rules that lack a `time_between` condition — they would fire uninhibited during the quiet window. The hint in each finding includes a ready-to-paste `time_between` condition to add. +switchbot rules suggest --intent "..." --llm auto # LLM-backed (OPENAI_API_KEY or ANTHROPIC_API_KEY) -Webhook trigger token management: - -```bash -switchbot rules webhook-rotate-token # rotate the bearer token for webhook triggers -switchbot rules webhook-show-token # print current token (creates one if absent) +switchbot rules trace-explain --rule "motion on" --last # why a rule fired/was blocked +switchbot rules simulate "motion on" --since 7d --json # replay without running the engine ``` -See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for -the engine's pipeline (subscribe → classify → match → conditions → -throttle → action → audit). +LLM-generated rules always have `dry_run: true` — flip it yourself after review. Notify URLs must be `http://` or `https://`. See [`docs/design/phase4-rules.md`](./docs/design/phase4-rules.md) for the full pipeline. ## Global options @@ -424,23 +328,11 @@ throttle → action → audit). - `-V`, `--version`: Print the CLI version. - `-h`, `--help`: Show help for any command or subcommand. -Every subcommand supports `--help`, and most include a parameter-format reference and examples. - -```bash -switchbot --help -switchbot devices command --help -``` - -> **Tip — required-value flags and subcommands.** Flags like `--profile`, `--timeout`, `--max`, and `--interval` take a value. If you omit it, Commander will happily consume the next token — including a subcommand name. Since v2.2.1 the CLI rejects that eagerly (exit 2 with a clear error), but if you ever hit `unknown command 'list'` after something like `switchbot --profile list`, use the `--flag=value` form: `switchbot --profile=home devices list`. +Every subcommand supports `--help`. Use `--flag=value` form when a flag takes a value and is followed by a subcommand (e.g. `switchbot --profile=home devices list`). ### `--dry-run` -Intercepts every non-GET request: the CLI prints the URL/body it would have -sent, then exits `0` without contacting the API. `GET` requests (list, status, -query) are still executed so you can preview the state involved. Dry-run also -validates command names against the device catalog and rejects unknown commands -(exit 2) when the device type has a known catalog entry. Commands sent to -read-only sensors (e.g. Meter) are likewise rejected. +Intercepts every non-GET request: prints the URL/body it would have sent, then exits `0`. GET requests still execute. Also validates command names against the device catalog (exit 2 on unknown commands or read-only sensors). ```bash switchbot devices command ABC123 turnOn --dry-run @@ -456,127 +348,44 @@ switchbot devices command ABC123 turnOn --dry-run switchbot config set-token # Save to ~/.switchbot/config.json switchbot config show # Print current source + masked secret switchbot config list-profiles # List saved profiles - -# Print (or write) the recommended AI-agent profile template -switchbot config agent-profile # print to stdout -switchbot config agent-profile --write # write to ~/.switchbot/profiles/agent.json (mode 0600) -switchbot config agent-profile --write --force # overwrite if it already exists -switchbot config agent-profile --json # structured JSON envelope +switchbot config agent-profile --write # write recommended AI-agent profile (mode 0600) ``` ### `devices` — list, status, control ```bash # List all physical devices and IR remote devices -# Default columns (4): deviceId, deviceName, type, category -# Pass --wide for the full 10-column operator view -switchbot devices list -switchbot devices ls # short alias for 'list' -switchbot devices list --wide +switchbot devices list # default 4 columns: deviceId, deviceName, type, category +switchbot devices list --wide # full 10-column operator view switchbot devices list --json | jq '.deviceList[].deviceId' - -# IR remotes: type = remoteType (e.g. "TV"), category = "ir" -# Physical: category = "physical" switchbot devices list --format=tsv --fields=deviceId,type,category -# Filter devices by type / name / category / room (server-side filter keys) -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 platform source -# header, which this CLI sends on every request) -switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")' -switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)' +# Filter by type / name / category / room +# Operators: = (substring; exact for category), ~ (substring), =/regex/; clauses AND-ed +switchbot devices list --filter 'type=Bot' +switchbot devices list --filter 'name~living,type=/Bulb|Strip/' +switchbot devices list --filter 'category=physical' -# Query real-time status of a physical device +# Query real-time status switchbot devices status -switchbot devices status --json +switchbot devices status --ids ABC,DEF,GHI # batch status +switchbot devices status --ids ABC,DEF --fields power,battery --format jsonl # Resolve device by fuzzy name instead of ID (status, command, describe, expand, watch) switchbot devices status --name "Living Room AC" switchbot devices command --name "Office Light" turnOn -switchbot devices describe --name "Kitchen Bot" - -# Batch status across multiple devices -switchbot devices status --ids ABC,DEF,GHI -switchbot devices status --ids ABC,DEF --fields power,battery # only show specific fields -switchbot devices status --ids ABC,DEF --format jsonl # one JSON line per device # Send a control command switchbot devices command [parameter] [--type command|customize] -# Describe a specific device (1 API call): metadata + supported commands + status fields -switchbot devices describe -switchbot devices describe --json - -# Discover what's supported (offline reference, no API call) -switchbot devices types # List all device types + IR remote types (incl. role column) -switchbot devices commands # Show commands, parameter formats, and status fields -switchbot devices commands Bot -switchbot devices commands "Smart Lock" -switchbot devices commands curtain # Case-insensitive, substring match +# Offline reference (no API call) +switchbot devices types # all device types +switchbot devices commands # commands, parameter formats, status fields ``` -#### Filter expressions — per-command reference - -Three commands accept `--filter`. They share one four-operator grammar, -but each exposes its own key set: - -- `devices list` - Operators: `=` (substring; **exact** for `category`), `!=` (negated), - `~` (substring), `=/regex/` (case-insensitive regex). - Keys: `type`, `name`, `category`, `room`. -- `devices batch` - Operators: same as `devices list`. - Keys: `type`, `family`, `room`, `category`. -- `events tail` / `events mqtt-tail` - Operators: same (tail only; mqtt-tail uses `--topic` instead). - Keys: `deviceId`, `type`. +Parameters for `setAll`, `setPosition`, `setMode`, `setBrightness`, and `setColor` are validated client-side (exit 2 on bad input). `setColor` accepts `R:G:B`, `#RRGGBB`, `#RGB`, and CSS names — all normalize to `R:G:B`. Pass `--skip-param-validation` to bypass. Unknown deviceIds exit 2 by default; pass `--allow-unknown-device` for scripted pass-through. -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. -A clause with an empty value (e.g. `name~`, `type=`) is rejected with exit 2 — -the parser refuses to guess whether an empty value means "no constraint" or -"match empty string". Drop the clause outright to remove the constraint. - -#### Parameter formats - -`parameter` is optional — omit it for commands like `turnOn`/`turnOff` (auto-defaults to `"default"`). -Numeric-only and JSON-object parameters are auto-parsed; strings with colons / commas / semicolons pass through as-is. - -For the exact commands and parameter formats a specific device supports, query the built-in catalog: - -```bash -switchbot devices commands # e.g. Bot, Curtain, "Smart Lock", "Robot Vacuum Cleaner S10" -``` - -Generic parameter shapes (which one applies is decided by the device — see the catalog): - -| Shape | Example | -| ------------------- | -------------------------------------------------------- | -| _(none)_ | `devices command turnOn` | -| `` | `devices command setBrightness 75` | -| `` | `devices command setColor "255:0:0"` | -| `` | `devices command setPosition "up;60"` | -| `` | `devices command setAll "26,1,3,on"` | -| `` | `'{"action":"sweep","param":{"fanLevel":2,"times":1}}'` | -| Custom IR button | `devices command MyButton --type customize` | - -Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). 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. - -Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Unknown command names and commands on read-only sensors are also rejected during dry-run when the device type has a catalog entry. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through. - -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). +For per-device command and parameter details: `switchbot devices commands ` or the [SwitchBot API docs](https://github.com/OpenWonderLabs/SwitchBotAPI#send-device-control-commands). #### `devices expand` — named flags for packed parameters @@ -596,61 +405,46 @@ switchbot devices expand setPosition --direction up --angle 50 # Relay Switch — setMode switchbot devices expand setMode --channel 1 --mode edge + +# Color Bulb / Strip Light / Floor Lamp / Ceiling Light — setBrightness / setColor / setColorTemperature +switchbot devices expand setBrightness --brightness 80 +switchbot devices expand setColor --color "#FF0000" +switchbot devices expand setColorTemperature --color-temp 4000 ``` -Run `switchbot devices expand --help` to see the available flags for any device command. `expand` is only meaningful for multi-parameter commands (the four above); single-parameter commands like `setBrightness 50` or `setColor "#FF0000"` are already flag-free at the CLI level. +Run `switchbot devices expand --help` to see the available flags for any device command. #### `devices explain` — one-shot device summary ```bash -# Metadata + supported commands + live status in one call -switchbot devices explain - -# Skip live status fetch (catalog-only output, no API call) -switchbot devices explain --no-live +switchbot devices explain # metadata + commands + live status +switchbot devices explain --no-live # catalog-only, no API call ``` -Returns a combined view: static catalog info (commands, parameters, status fields) merged with the current live status. For Hub devices, also lists connected child devices. Prefer this over separate `status` + `describe` calls. - #### `devices meta` — local device metadata ```bash switchbot devices meta set --alias "Office Light" -switchbot devices meta set --hide # hide from `devices list` +switchbot devices meta set --hide # hide from `devices list` switchbot devices meta get -switchbot devices meta list # show all saved metadata +switchbot devices meta list switchbot devices meta clear ``` -Stores local annotations (alias, hidden flag, notes) in `~/.switchbot/device-meta.json`. The alias is used as a display name; `--show-hidden` on `devices list` reveals hidden devices. +Stores local annotations in `~/.switchbot/device-meta.json`. `--show-hidden` on `devices list` reveals hidden devices. #### `devices batch` — bulk commands ```bash -# Send the same command to every device matching a filter +# Same command to every matching device switchbot devices batch turnOff --filter 'type=Bot' switchbot devices batch setBrightness 50 --filter 'type~Light,family=Living' - -# Explicit device IDs (comma-separated) switchbot devices batch turnOn --ids ID1,ID2,ID3 - -# Pipe device IDs from `devices list` switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle - - -# 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 - -switchbot devices batch turnOn --ids ID1,ID2 --idempotency-key morning-lights +switchbot devices batch unlock --filter 'type=Smart Lock' --yes # destructive: requires --yes ``` -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'`. +Filter keys: `type`, `family`, `room`, `category`. Skipped-offline devices appear under `summary.skipped` when `--skip-offline` is passed. ### `scenes` — run manual scenes @@ -685,140 +479,39 @@ The CLI validates that `` is an absolute `http://` or `https://` URL before ### `events` — receive device events -Two subcommands cover the two ways SwitchBot can push state changes to you. - #### `events tail` — local webhook receiver ```bash -# Listen on port 3000 and print every incoming webhook POST -switchbot events tail - -# Filter to one device -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 # listen on port 3000 +switchbot events tail --filter deviceId=ABC123 # filter to one device +switchbot events tail --filter 'type=WoMeter' --max 5 --for 10m switchbot events tail --port 8080 --path /hook --json ``` -Run `switchbot webhook setup https://your.host/hook` first to tell SwitchBot where to send events, then expose the local port via ngrok/cloudflared and point the webhook URL at it. `events tail` only runs the local receiver — tunnelling is up to you. - -Output (one JSON line per matched event): - -```json -{ "t": "2024-01-01T12:00:00.000Z", "remote": "1.2.3.4:54321", "path": "/", "body": {...}, "matched": true } -``` - -Filter keys: `deviceId`, `type`. Operators: `=` (substring), `~` (substring), `=/regex/` (case-insensitive regex). Clauses comma-separated and AND-ed. +Run `switchbot webhook setup https://your.host/hook` first. `events tail` only runs the local receiver — tunnelling (ngrok/cloudflared) is up to you. #### `events mqtt-tail` — real-time MQTT stream ```bash -# Stream all shadow-update events (runs in foreground until Ctrl-C) -switchbot events mqtt-tail - -# Filter to a topic subtree -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 +switchbot events mqtt-tail # stream all shadow events (Ctrl-C to stop) +switchbot events mqtt-tail --topic 'switchbot/#' # filter to topic subtree +switchbot events mqtt-tail --max 10 --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. - -Output (one JSON line per message): - -```json -{ "t": "2024-01-01T12:00:00.000Z", "topic": "switchbot/abc123/status", "payload": {...} } -``` - -This command runs in the foreground and streams events until you press Ctrl-C. To run it persistently in the background, use a process manager: - -```bash -# pm2 -pm2 start "switchbot events mqtt-tail --json" --name switchbot-events - -# nohup -nohup switchbot events mqtt-tail --json >> ~/switchbot-events.log 2>&1 & -``` - -Run `switchbot doctor` to verify MQTT credentials are configured correctly before connecting. +Credentials are provisioned automatically from the REST API config. Use `--sink` to route events to external services (`file`, `webhook`, `telegram`, `homeassistant`, `openclaw`) — see `switchbot events mqtt-tail --help` for details. ### `status-sync` — MQTT/OpenClaw bridge -Use this command family when you want the CLI itself to own the lifecycle of a -long-running bridge that forwards SwitchBot MQTT shadow events into an OpenClaw -gateway. Internally it reuses `events mqtt-tail --sink openclaw`, but adds a -stable command surface for foreground execution, background startup, status -inspection, and shutdown. +Forwards SwitchBot MQTT shadow events into an OpenClaw gateway with stable lifecycle management. ```bash -# Foreground mode for supervisors / containers -switchbot status-sync run --openclaw-model home-agent - -# Background mode for a normal shell session -switchbot status-sync start --openclaw-model home-agent - -# Inspect the current bridge +switchbot status-sync run --openclaw-model home-agent # foreground (for supervisors) +switchbot status-sync start --openclaw-model home-agent # background switchbot status-sync status --json - -# Stop the running bridge switchbot status-sync stop ``` -Required input: - -- `OPENCLAW_MODEL` or `--openclaw-model ` -- `OPENCLAW_TOKEN` or `--openclaw-token ` - -Optional input: - -- `OPENCLAW_URL` or `--openclaw-url ` -- `--topic ` to narrow the MQTT subscription -- `SWITCHBOT_STATUS_SYNC_HOME` or `--state-dir ` for custom runtime state - -Background mode writes these files under the state directory: - -- `state.json` — current pid, start time, effective command -- `stdout.log` — child stdout -- `stderr.log` — child stderr - -Foreground vs background: - -- `status-sync run` keeps the bridge attached to the current terminal -- `status-sync start` detaches the bridge and returns immediately -- `status-sync status` reports whether the bridge is alive plus paths/logs -- `status-sync stop` terminates the managed bridge process tree - -#### `mqtt-tail` sinks — route events to external services - -By default `mqtt-tail` prints JSONL to stdout. Use `--sink` (repeatable) to route events to one or more destinations instead: - -| Sink | Required flags | -| --- | --- | -| `stdout` | (default when no `--sink` given) | -| `file` | `--sink-file ` — append JSONL | -| `webhook` | `--webhook-url ` — HTTP POST each event | -| `telegram` | `--telegram-token` (or `$TELEGRAM_TOKEN`), `--telegram-chat ` | -| `homeassistant` | `--ha-url ` + `--ha-webhook-id` (no auth) or `--ha-token` (REST event API) | - -```bash -# Generic webhook (n8n, Make, etc.) -switchbot events mqtt-tail --sink webhook --webhook-url https://n8n.local/hook/abc - -# Forward to Home Assistant via webhook trigger -switchbot events mqtt-tail --sink homeassistant --ha-url http://homeassistant.local:8123 --ha-webhook-id switchbot -``` - -Device state is also persisted to `~/.switchbot/device-history/.json` (latest + 100-entry ring buffer) regardless of sink configuration. This enables the `get_device_history` MCP tool to answer state queries without an API call. +Required: `OPENCLAW_MODEL` (or `--openclaw-model`) and `OPENCLAW_TOKEN`. Optional: `OPENCLAW_URL`, `--topic`, `--state-dir`. Background mode writes `state.json`, `stdout.log`, and `stderr.log` under the state directory. ### `daemon` — background rules-engine process @@ -917,10 +610,11 @@ switchbot mcp serve ``` Exposes MCP tools (`list_devices`, `describe_device`, `get_device_status`, +`get_device_history`, `query_device_history`, `aggregate_device_history`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`, `account_overview`, `plan_suggest`, `plan_run`, `audit_query`, `audit_stats`, `policy_diff`, `policy_validate`, `policy_new`, -`policy_migrate`, `rules_suggest`, `rule_notifications`, +`policy_migrate`, `policy_add_rule`, `rules_suggest`, `rule_notifications`, `rules_explain`, `rules_simulate`) plus a `switchbot://events` resource for real-time shadow updates. `rules_suggest` accepts an optional `llm` parameter (`openai | anthropic | auto`) @@ -939,7 +633,7 @@ switchbot doctor switchbot doctor --json ``` -Runs local checks (Node version, credentials, profiles, catalog, cache, quota, clock, MQTT, policy, MCP, notify-connectivity) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. +Runs local checks (Node version, credentials, profiles, catalog, catalog-schema, cache, quota, clock, MQTT, policy, MCP, keychain, path, inventory, audit, daemon, health, notify-connectivity, release-notes) and exits 1 if any check fails. `warn` results exit 0. The MQTT check reports `ok` when REST credentials are configured (auto-provisioned on first use). The `notify-connectivity` check probes webhook URLs declared in `type: notify` actions. Use this to diagnose connectivity or config issues before running automation. `--json` output includes `maturityScore` (0–100) and `maturityLabel` (`production-ready` / `mostly-ready` / `needs-work` / `not-ready`) to give an at-a-glance readiness rating: @@ -970,71 +664,44 @@ Port conflicts are reported immediately with a clear hint to choose a different ### `upgrade-check` — version check ```bash -switchbot upgrade-check # human output; exits 1 when update available -switchbot upgrade-check --json # structured JSON output -switchbot upgrade-check --timeout 5000 # custom registry timeout (ms) -``` - -Queries the npm registry for the latest published version and compares it against the running version. When the registry's `dist-tags.latest` is itself a prerelease (e.g. `4.0.0-rc.1`), the check is skipped and the current version is treated as up-to-date — accidental prerelease tags don't trigger spurious upgrade prompts. -`--json` output: - -```json -{ - "current": "3.3.2", - "latest": "4.0.0", - "upToDate": false, - "updateAvailable": true, - "breakingChange": true, - "installCommand": "npm install -g @switchbot/openapi-cli@4.0.0" -} +switchbot upgrade-check # exits 1 when update available +switchbot upgrade-check --json # {current, latest, upToDate, updateAvailable, breakingChange, installCommand} ``` -`breakingChange` is `true` when the latest major version is higher than the current — useful for agents or CI that need to distinguish breaking upgrades from patch releases. - ### `quota` — API request counter ```bash -switchbot quota status # today's usage + last 7 days -switchbot quota reset # delete the counter file +switchbot quota status # today's usage + last 7 days (10,000/day limit) +switchbot quota reset ``` -Tracks daily API calls against the 10,000/day account limit. The counter is stored in `~/.switchbot/quota.json` and incremented on every mutating request. Pass `--no-quota` to skip tracking for a single run. - ### `history` — audit log ```bash -switchbot history show # recent entries (newest first) -switchbot history show --limit 20 # last 20 entries -switchbot history replay 7 # re-run entry #7 +switchbot history show --limit 20 +switchbot history replay 7 # re-run entry #7 switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")' ``` -Reads the JSONL audit log (`~/.switchbot/audit.log` by default; override with `--audit-log --audit-log-path `). Each entry records the timestamp, command, device ID, result, and dry-run flag. `replay` re-runs the original command with the original arguments. - ### `catalog` — device type catalog ```bash -switchbot catalog show # all 42 built-in types -switchbot catalog list # alias for `show` +switchbot catalog show # all built-in types switchbot catalog show Bot # one type -switchbot catalog search Hub # fuzzy match across type / aliases / commands -switchbot catalog diff # what a local overlay changes vs built-in -switchbot catalog path # location of the local overlay file -switchbot catalog refresh # reload local overlay (clears in-process cache) +switchbot catalog search Hub # fuzzy match +switchbot catalog diff # local overlay vs built-in ``` -The built-in catalog ships with the package. Create `~/.switchbot/catalog-overlay.json` to add, extend, or override type definitions without modifying the package. +Create `~/.switchbot/catalog-overlay.json` to extend or override type definitions without modifying the package. ### `schema` — export catalog as JSON ```bash -switchbot schema export # all types as structured JSON -switchbot schema export --type 'Strip Light' # one type -switchbot schema export --role sensor # filter by role +switchbot schema export # all types +switchbot schema export --type 'Strip Light' +switchbot schema export --role sensor ``` -Exports the effective catalog in a machine-readable format. Pipe the output into an agent's system prompt or tool schema to give it a complete picture of controllable devices. - ### `capabilities` — CLI manifest ```bash @@ -1042,127 +709,58 @@ switchbot capabilities --json switchbot capabilities --used --json # only types seen in the local cache ``` -Prints a versioned JSON manifest describing available surfaces (CLI, MCP, MQTT, plan runner), commands, and environment variables. Every subcommand leaf now carries a `{mutating, consumesQuota, idempotencySupported, agentSafetyTier, verifiability, typicalLatencyMs}` block, and the top-level payload publishes a flat `commandMeta` path-keyed lookup so agents don't have to walk the tree. `--used` filters the per-type summary to devices actually present in the local cache (same semantics as `schema export --used`). +Prints a versioned manifest of surfaces, commands, and environment variables. Each command leaf includes `{mutating, consumesQuota, agentSafetyTier, typicalLatencyMs}`. ### `cache` — inspect and clear local cache ```bash -# Show cache status (paths, age, entry counts) -switchbot cache show - -# Clear everything -switchbot cache clear - -# Clear only the device-list cache or only the status cache -switchbot cache clear --key list -switchbot cache clear --key status +switchbot cache show # paths, age, entry counts +switchbot cache clear # clear everything +switchbot cache clear --key list # list cache only +switchbot cache clear --key status # status cache only ``` ### `policy` — validate, scaffold, and migrate policy.yaml -Companion to the separate SwitchBot skill repository for third-party agent hosts. The skill reads behaviour (aliases, confirmations, quiet hours, audit path) from `policy.yaml`. This command group checks that file before the skill ever sees it, turning what used to be silent failures into line-accurate errors. - ```bash -# Write a starter policy at the default location -switchbot policy new # writes to the resolved default policy path -switchbot policy new ./custom/policy.yaml --force - -# Validate (compiler-style errors with line:col + caret + hints) -switchbot policy validate -switchbot policy validate ./custom/policy.yaml +switchbot policy new # write a starter policy +switchbot policy validate # compiler-style errors (line:col + caret) switchbot policy validate --json | jq '.data.errors' -switchbot policy validate --no-snippet # plain error list, no source preview - -# Report the schema version the file declares -switchbot policy migrate - -# Snapshot and restore the active policy -switchbot policy backup # write timestamped backup alongside policy file -switchbot policy backup --out ./backups/ # custom destination directory -switchbot policy restore # overwrite active policy from backup (auto-backups first) +switchbot policy migrate # upgrade v0.1 → v0.2 in-place +switchbot policy backup # timestamped backup +switchbot policy restore ``` -Path resolution order: positional `[path]` > `SWITCHBOT_POLICY_PATH` env var > default policy path. - -**Exit codes:** `0` valid / `1` invalid / `2` file-not-found / `3` yaml-parse / `4` internal / `5` file already exists (on `new`, overridden with `--force`) / `6` unsupported schema version (on `migrate`). - -Example — editing an alias without quoting the deviceId: - -```console -$ switchbot policy validate -:14:11 - 14 | bedroom light: 01-abc-12345 - ^^^^^^^^^^^^^ -error: /aliases/bedroom light does not match pattern ^[A-Z0-9]{2,}-[A-Z0-9-]+$ -hint: paste the deviceId from `switchbot devices list --format=tsv`, e.g. 01-202407090924-26354212 - -✗ 1 error in (schema v0.1) -``` - -The default policy schema shipped with the CLI (`src/policy/schema/v0.2.json`) is mirrored as `examples/policy.schema.json` in the companion skill repo; a CI job on every push diffs the two to prevent drift. +Path resolution: positional `[path]` > `SWITCHBOT_POLICY_PATH` > default. Exit codes: `0` valid / `1` invalid / `2` missing / `3` yaml-parse / `4` internal / `5` exists (use `--force`) / `6` unsupported version. ## Output modes - **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details. -- **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on stderr: `{ "error": { "code", "kind", "message", "hint?" } }`. -- **`--format=json`** — projected row view. Same JSON structure but built from the CLI's column model (`--fields` applies). Use this when you only want specific fields. -- **`--format=tsv|yaml|jsonl|id`** — tabular text formats; `--fields` filters columns. +- **`--json`** — raw API payload passthrough. Errors are also JSON on **stdout**: `{ "schemaVersion": "1.2", "error": { "code", "kind", "message", "hint?" } }`. +- **`--format=json`** — projected row view; `--fields` applies. +- **`--format=tsv|yaml|jsonl|id`** — tabular text formats. ```bash -# Raw API payload (--json) switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}' - -# Projected rows with field filter (--format) switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud switchbot devices list --format id # one deviceId per line -switchbot devices status --format yaml ``` ## Cache -The CLI maintains two local disk caches under `~/.switchbot/`: - -- `devices.json`: Device metadata (id, name, type, category, hub, room…). - Default TTL: 1 hour. -- `status.json`: Per-device status bodies. - Default TTL: off (0). +Two local disk caches under `~/.switchbot/`: -The device-list cache powers offline validation (command name checks, destructive-command guard) and the MCP server's `send_command` tool. It is refreshed automatically on every `devices list` call. - -### Cache control flags +| Cache | Default TTL | Purpose | +|---|---|---| +| `devices.json` | 1 hour | device metadata; powers offline validation | +| `status.json` | off | per-device status; GC'd after 24h | ```bash -# Turn off all cache reads for one invocation -switchbot devices list --no-cache - -# Set both list and status TTL to 5 minutes -switchbot devices status --cache 5m - -# Set TTLs independently +switchbot devices list --no-cache # bypass for one invocation +switchbot devices status --cache 5m # set list + status TTL switchbot devices status --cache-list 2h --cache-status 30s - -# Disable only the list cache (keep status cache at its current TTL) -switchbot devices list --cache-list 0 ``` -### Cache management commands - -```bash -# Show paths, age, and entry counts -switchbot cache show - -# Clear all cached data -switchbot cache clear - -# Scope the clear to one store -switchbot cache clear --key list -switchbot cache clear --key status -``` - -### Status-cache GC - -`status.json` entries are automatically evicted after 24 hours (or 10× the configured status TTL, whichever is longer), so the file cannot grow without bound even when the status cache is left enabled long-term. - ## Exit codes & error codes - `0`: Success (including `--dry-run` intercept when validation passes). @@ -1209,112 +807,22 @@ npm install npm run dev -- # Run from TypeScript sources via tsx npm run build # Compile to dist/ -npm test # Run the Vitest suite (2204 tests) +npm test # Run the Vitest suite (2225 tests) npm run test:watch # Watch mode npm run test:coverage # Coverage report (v8, HTML + text) ``` -### Project layout - -```text -src/ -├── index.ts # Commander entry; mounts all subcommands; global flags -├── auth.ts # HMAC-SHA256 signature (token + t + nonce → sign) -├── config.ts # Credential load/save; env > keychain > file priority -├── api/client.ts # axios instance + request/response interceptors; -│ # --verbose / --dry-run / --timeout wiring -├── credentials/ -│ ├── keychain.ts # Credential store interface + OS backend selection -│ └── backends/ # macos.ts / linux.ts / windows.ts / file.ts -├── devices/ -│ ├── catalog.ts # Static device catalog (commands, params, status fields) -│ └── cache.ts # Disk + in-memory cache for device list and status -├── install/ -│ ├── steps.ts # Generic step runner with rollback support -│ ├── preflight.ts # Pre-flight checks (Node, npm, network, agent) -│ └── default-steps.ts # Concrete steps: credentials, keychain, policy, skill, doctor -├── policy/ -│ ├── validate.ts # Schema version dispatch + JSON Schema validation -│ ├── migrate.ts # v0.1 → v0.2 migration -│ ├── load.ts # YAML file loading + error handling -│ ├── add-rule.ts # Rule injection into automation.rules[] -│ ├── diff.ts # Structural + line diff -│ └── schema/v0.2.json # Authoritative v0.2 JSON Schema -├── rules/ -│ ├── engine.ts # Main orchestrator (MQTT + cron + webhook) -│ ├── matcher.ts # Trigger + condition matchers -│ ├── action.ts # Command renderer + executor -│ ├── throttle.ts # Per-rule throttle gate -│ ├── cron-scheduler.ts # 5-field cron + days filter -│ ├── webhook-listener.ts # HTTP listener (bearer token, localhost-only) -│ ├── pid-file.ts # Hot-reload via SIGHUP or sentinel file -│ ├── audit-query.ts # Audit log filtering + aggregation -│ ├── conflict-analyzer.ts # Static conflict detection (opposing actions, -│ │ # high-freq MQTT, destructive cmds, quiet-hours gaps) -│ ├── suggest.ts # Heuristic + LLM-backed rule YAML generation -│ ├── notify.ts # notify action executor (webhook / file / openclaw) -│ └── types.ts # Shared rule/trigger/condition/action types (CommandAction | NotifyAction) -├── llm/ -│ ├── index.ts # createLLMProvider factory + LLM_AUTO_THRESHOLD -│ ├── complexity.ts # Intent complexity scorer (0–10) for auto-routing -│ ├── rule-prompt.ts # System prompt builder (embeds v0.2 schema snippet) -│ └── providers/ -│ ├── openai.ts # OpenAI-compatible provider (uses Node.js https) -│ └── anthropic.ts # Anthropic provider -├── status-sync/ -│ └── manager.ts # Spawn/stop logic, state file, OpenClaw bridge -├── lib/ -│ └── devices.ts # Shared logic: listDevices, describeDevice, isDestructiveCommand -├── commands/ -│ ├── auth.ts # `auth keychain` subcommand group -│ ├── config.ts -│ ├── devices.ts -│ ├── expand.ts # `devices expand` — semantic flag builder -│ ├── explain.ts # `devices explain` — one-shot device summary -│ ├── device-meta.ts # `devices meta` — local aliases / hide flags -│ ├── install.ts # `switchbot install` / `uninstall` -│ ├── policy.ts # `policy validate/new/migrate/diff/add-rule/backup/restore` -│ ├── rules.ts # `rules suggest/lint/list/explain/run/reload/tail/replay/ -│ │ # conflicts/doctor/summary/last-fired/webhook-*/ -│ │ # trace-explain/simulate` -│ ├── scenes.ts -│ ├── health.ts # `health check/serve` — report + HTTP endpoints -│ ├── upgrade-check.ts # `upgrade-check` — npm registry version check -│ ├── status-sync.ts # `status-sync run/start/stop/status` -│ ├── webhook.ts -│ ├── watch.ts # `devices watch ` -│ ├── events.ts # `events tail` / `events mqtt-tail` -│ ├── mcp.ts # `mcp serve` (MCP stdio/HTTP server) -│ ├── plan.ts # `plan run/validate/suggest` -│ ├── cache.ts # `cache show/clear` -│ ├── history.ts # `history show/replay` -│ ├── quota.ts # `quota status/reset` -│ ├── catalog.ts # `catalog show/diff/path` -│ ├── schema.ts # `schema export` -│ ├── doctor.ts # `doctor` -│ ├── capabilities.ts # `capabilities` -│ └── completion.ts # `completion bash|zsh|fish|powershell` -└── utils/ - ├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …) - ├── output.ts # printTable / printKeyValue / printJson / handleError - ├── format.ts # renderRows / filterFields / output-format dispatch - ├── audit.ts # JSONL audit log writer - └── quota.ts # Local daily-quota counter -tests/ # Vitest suite (2204 tests, mocked axios, no network) -``` +Source layout: `src/commands/` (one file per command group), `src/devices/` (catalog + cache), `src/rules/` (engine, matcher, throttle, audit), `src/policy/` (validate, migrate, schema), `src/llm/` (providers), `src/utils/` (output, format, flags). Tests are in `tests/` and mirror the `src/` structure. ### Release flow -Releases are cut on tag push and published to npm by GitHub Actions: - ```bash -npm version patch # bump version + create git tag +npm version patch # bump + create git tag git push --follow-tags +# then: GitHub → Releases → Draft → Publish ``` -Then on GitHub → **Releases → Draft a new release → select tag → Publish**. The `publish.yml` workflow runs tests, verifies the tag matches `package.json`, and publishes `@switchbot/openapi-cli` to npm with [provenance](https://docs.npmjs.com/generating-provenance-statements). - -See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full pre-publish and post-publish verification flow (local hooks → CI → `publish.yml` → `npm-published-smoke.yml`). +See [`docs/release-pipeline.md`](./docs/release-pipeline.md) for the full CI / publish verification flow. ## License diff --git a/docs/agent-guide.md b/docs/agent-guide.md index 03086d9..b857856 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -76,7 +76,7 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) } ``` -### Available tools (21) +### Available tools (24) | Tool | Purpose | Safety tier | | --- | --- | --- | @@ -100,6 +100,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) | `audit_query` | Filter audit log entries | read | | `audit_stats` | Aggregate audit stats by kind/result/device/rule | read | | `rules_suggest` | Draft automation rule YAML from intent | read | +| `rule_notifications` | Query rule notification delivery history | read | +| `rules_explain` | Show why a rule evaluation fired or was blocked | read | +| `rules_simulate` | Simulate a rule against historical events | read | | `policy_add_rule` | Inject rule YAML into `automation.rules[]` with diff | action | The MCP server refuses destructive commands (Smart Lock `unlock`, Garage Door `open`, etc.) unless the tool call includes `confirm: true`, and the default safety profile still blocks direct destructive execution in favor of the reviewed CLI flow (`plan save` → `plan review` → `plan approve` → `plan execute`). The allowed list is the `destructive: true` commands in the catalog — `switchbot schema export | jq '[.data.types[].commands[] | select(.destructive)]'` shows every one. diff --git a/docs/audit-log.md b/docs/audit-log.md index 3a9ebb2..f77a9e7 100644 --- a/docs/audit-log.md +++ b/docs/audit-log.md @@ -16,9 +16,9 @@ Every record is a JSON object with at least the following fields: | Field | Type | Notes | |----------------|----------------------|----------------------------------------------------------------------------------------| -| `auditVersion` | number | Schema version. Current: `1`. Missing on records written before audit versioning. | +| `auditVersion` | number | Schema version. Current: `2`. Missing on records written before audit versioning. | | `t` | string (ISO-8601) | Timestamp when the record was written. | -| `kind` | `"command"` | Record discriminator. Currently the only kind is `command`. | +| `kind` | string | Record discriminator. Values: `command`, `rule-fire`, `rule-fire-dry`, `rule-throttled`, `rule-webhook-rejected`, `rule-notify`, `rule-evaluate`, `llm-suggest`, `llm-condition`, `llm-budget-exceeded`. | | `deviceId` | string | Target device ID. | | `command` | string | SwitchBot command name (e.g. `turnOn`, `setColor`). | | `parameter` | string \| object | Command parameter as sent — `"default"` when unused. | @@ -30,7 +30,7 @@ Every record is a JSON object with at least the following fields: ### Example ```json -{"auditVersion":1,"t":"2026-04-20T01:23:45.123Z","kind":"command","deviceId":"ABC123","command":"turnOn","parameter":"default","commandType":"command","dryRun":false,"result":"ok"} +{"auditVersion":2,"t":"2026-04-20T01:23:45.123Z","kind":"command","deviceId":"ABC123","command":"turnOn","parameter":"default","commandType":"command","dryRun":false,"result":"ok"} ``` ## Crash safety diff --git a/docs/design/phase4-rules.md b/docs/design/phase4-rules.md index f1cb860..f42c41d 100644 --- a/docs/design/phase4-rules.md +++ b/docs/design/phase4-rules.md @@ -145,12 +145,25 @@ were folded into the composite nodes above). ## Actions -Each `then[]` entry renders to: +Each `then[]` entry is one of two types: + +**`type: command`** (default) — renders to: ``` switchbot substituted> --audit-log ``` +**`type: notify`** — delivers a payload to an external channel: + +```yaml +- type: notify + channel: webhook # webhook | file | openclaw + to: https://your.host/hook + template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' +``` + +Channels: `webhook` (HTTP POST), `file` (append JSONL), `openclaw` (HTTP POST). Template supports `{{ rule.name }}`, `{{ event.* }}`, `{{ device.id }}` placeholders. Audit gains `rule-notify` kind for every notify dispatch. + Rules: 1. **Safety tier gates still apply.** If the rendered command is diff --git a/docs/design/roadmap.md b/docs/design/roadmap.md index 0b9d975..2c1f48d 100644 --- a/docs/design/roadmap.md +++ b/docs/design/roadmap.md @@ -32,7 +32,7 @@ points back to. | Capability | This repo (`switchbot-openapi-cli`) | Cross-repo (`+ companion skill repo`) | Notes | | --- | --- | --- | --- | | Phase 1 (manual orchestration) | Shipped | Shipped | Stable in v2.7.x | -| Phase 2 (policy tooling) | Shipped | Shipped | v0.1 + v0.2 policy schema support | +| Phase 2 (policy tooling) | Shipped | Shipped | v0.2 policy schema (v0.1 removed in v3.0) | | Phase 3A (keychain + install CLI) | Shipped | Shipped | `switchbot install` / `switchbot uninstall` | | Phase 3B (skill packaging + external registry) | External tracking only | In progress outside this repo | Owned by companion skill repo | | Phase 4 (rules engine, v0.2 model) | Shipped | Shipped | MQTT/cron/webhook + `days` + `all`/`any`/`not` | @@ -81,7 +81,7 @@ reads it, the MCP server reads it, and `doctor` reports on it. Surfaces: -- `policy new | validate | migrate | diff` (v0.1 and v0.2 schemas) +- `policy new | validate | migrate | diff` (v0.2 schema; v0.1 removed in v3.0) - Default `policy.yaml` discovery rules - Aliases (human-readable device names) - Quiet hours (local-time windows, midnight-crossing supported) @@ -199,23 +199,19 @@ the skill's `manifest.json` `roadmap` block, which points back here. ## Next execution queue (ordered) -1. **v0.1 policy deprecation window (post-default-flip hardening).** - Keep validating v0.1, but emit explicit migration guidance in UX/docs. - Exit when: policy docs and CLI examples consistently steer new users to - v0.2, and migration guidance is visible in `policy migrate` help. -2. **Daemon mode for repeated agent invocations.** +1. **Daemon mode for repeated agent invocations.** Add a local long-lived process with Unix socket / named pipe transport. Exit when: repeated MCP + plan runs no longer pay fresh-process startup, and `doctor` can verify daemon health. -3. **Standalone MCP package (`npx @switchbot/mcp-server`).** +2. **Standalone MCP package (`npx @switchbot/mcp-server`).** Split MCP serve entrypoint into a tiny publishable package while preserving tool contract parity with the main CLI. Exit when: `npx @switchbot/mcp-server` boots and passes the same MCP contract tests as `switchbot mcp serve`. -4. **`switchbot self-test` command.** +3. **`switchbot self-test` command.** Add scripted go/no-go checks for credentials + one representative device. Exit when: CI can run a deterministic self-test job with pass/fail JSON. -5. **Record/replay fixtures for deterministic integration tests.** +4. **Record/replay fixtures for deterministic integration tests.** Capture request/response transcripts and replay offline in CI. Exit when: at least one full scenario (list → status → command guard) is replayable without live API calls. diff --git a/docs/json-contract.md b/docs/json-contract.md index bb60eac..5483e3d 100644 --- a/docs/json-contract.md +++ b/docs/json-contract.md @@ -20,7 +20,7 @@ stdout. ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "data": } ``` @@ -34,7 +34,7 @@ stdout. ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "error": { "code": 2, "kind": "usage" | "guard" | "api" | "runtime", @@ -82,12 +82,14 @@ envelope: ### Stream header (always the first line under `--json`) ```json -{ "schemaVersion": "1", "stream": true, "eventKind": "tick" | "event", "cadence": "poll" | "push" } +{ "schemaVersion": "1.2", "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. +- `schemaVersion` is `"1.2"` for `devices watch` and `"1"` for + `events tail` / `events mqtt-tail`. - `eventKind` picks the downstream parser. `tick` → `devices watch` shape with `{ t, tick, deviceId, changed, ... }`. `event` → unified event envelope (see below). @@ -125,7 +127,7 @@ envelope: ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "data": { "t": "2026-04-21T14:23:45.012Z", "tick": 1, @@ -184,9 +186,10 @@ switchbot devices status BOT1 --json | jq -e '.error' && exit 1 ## 4. Versioning -- The non-streaming envelope is versioned as `schemaVersion: "1.1"`. +- The non-streaming envelope is versioned as `schemaVersion: "1.2"`. - The streaming header and event envelope are versioned as - `schemaVersion: "1"`. + `schemaVersion: "1"` for `events tail` / `events mqtt-tail`, and + `"1.2"` for `devices watch`. - 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 diff --git a/docs/policy-reference.md b/docs/policy-reference.md index 3f951d8..7a1b9cc 100644 --- a/docs/policy-reference.md +++ b/docs/policy-reference.md @@ -8,7 +8,7 @@ edit the generated file — every block in it is commented with a summary. The JSON Schema that backs this document lives at -`src/policy/schema/v0.1.json` (Draft 2020-12). It is also mirrored to +`src/policy/schema/v0.2.json` (Draft 2020-12). It is also mirrored to `examples/policy.schema.json` for editor autocomplete. --- @@ -37,22 +37,27 @@ memory, and writes back. The top-level `version` field is **required**. The CLI currently supports two schemas: -| Version | Emitted by `policy new` | What it adds | +| Version | Status | What it adds | |---|---|---| -| `"0.1"` | Default (today) | aliases, confirmations, quiet_hours, audit, cli | -| `"0.2"` | Opt-in via `policy migrate` | typed `automation.rules[]` for the preview rules engine | +| `"0.1"` | **Removed in v3.0** — migrate with `policy migrate` (CLI ≤2.15) | aliases, confirmations, quiet_hours, audit, cli | +| `"0.2"` | **Current (required)** | typed `automation.rules[]` for the rules engine | -A file with anything other than `"0.1"` or `"0.2"` fails validation -with a named `unsupported-version` error. When the rules engine exits -preview and v0.2 becomes the default, `switchbot policy migrate` will -continue to be an opt-in upgrade — comments and non-version blocks -are preserved verbatim, and the command refuses to rewrite the file -if the upgraded document would not validate (exit code 7). +A file with anything other than `"0.2"` fails validation +with a named `unsupported-version` error. v0.2 is the default emitted +by `switchbot policy new`. Existing v0.1 files must be migrated using +CLI ≤2.15 before upgrading to v3.0+: + +```bash +switchbot policy migrate # in-place upgrade, preserves comments +``` + +`policy migrate` applies additive changes only (new optional fields, +tighter types on reserved blocks), rewrites the `version` constant, and +refuses to migrate if any user edits would conflict (exit code 7). ```yaml -version: "0.1" # stable today -# or -version: "0.2" # opt-in for rules engine preview +version: "0.2" # current default +# version: "0.1" # legacy — upgrade with `switchbot policy migrate` ``` --- @@ -91,10 +96,9 @@ Rules: - Keys are free-form strings. Quote them if they contain spaces or non-ASCII characters. -- Values must match `^[A-Z0-9]{2,}-[A-Z0-9-]+$` — SwitchBot deviceIds - are uppercase. A lowercase deviceId is the #1 cause of validation - failures. -- Get IDs from `switchbot devices list --format=tsv`. +- Values must match `^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$` — also accepts + hex MAC format and hyphenated multi-segment IDs. + Get IDs from `switchbot devices list --format=tsv`. --- @@ -175,15 +179,19 @@ PowerShell scheduled task, etc.) should honour the value. ### `automation` -Rule engine block. In **v0.1** this is a reserved stub — set -`enabled: false` (the default) and ignore it; the CLI prints a warning -and skips the block if you flip `enabled: true` on v0.1. In **v0.2** -this block drives the preview rules engine exposed by -`switchbot rules run`. +Rule engine block. Available in **v0.2** — set `enabled: true` to activate +`switchbot rules run`. In **v0.1** this block is a reserved stub; flip +`enabled: true` on v0.1 and the CLI prints a warning and skips the block. +Run `switchbot policy migrate` first to unlock the rules engine. ```yaml automation: enabled: true # must be true for `rules run` to do anything + audit: + evaluate_trace: sampled # full | sampled | off (default sampled) + evaluate_retention_days: 7 # min 1 (default 7) + llm_budget: + max_calls_per_hour: 60 # global limit across all LLM conditions (default 60) rules: - name: hallway motion at night # unique per file; audit label enabled: true # default true; false silences the rule @@ -198,21 +206,32 @@ automation: device: hallway lamp # alias resolves to deviceId at fire time args: null # optional map of verb arguments on_error: continue # continue (default) | stop + - type: notify + channel: webhook # webhook | file | openclaw + to: https://your.host/hook + template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}' + on_failure: log # log | retry | ignore throttle: max_per: "10m" # minimum spacing: \d+[smh] + dedupe_window: null # event deduplication window + cooldown: null # shorthand for throttle.max_per + requires_stable_for: null # hysteresis guard duration + maxFiringsPerHour: null # per-hour rate limit + suppressIfAlreadyDesired: false # skip if device already in desired state dry_run: true # default true in v0.2; writes audit but skips the API call ``` **Trigger sources (v0.2).** -| `source` | Required fields | Status in PoC | +| `source` | Required fields | Status | |-----------|------------------------|----------------------------------| | `mqtt` | `event` (+ `device?`) | **active** — fires on shadow MQTT | -| `cron` | `schedule` (5-field) | parsed; `rules lint` flags `unsupported` | -| `webhook` | `path` | parsed; `rules lint` flags `unsupported` | +| `cron` | `schedule` (5-field) | **active** — local time, optional `days` weekday filter | +| `webhook` | `path` | **active** — bearer-token HTTP ingest | MQTT event names classified today: `motion.detected`, -`motion.cleared`, `contact.opened`, `contact.closed`. Unmatched +`motion.cleared`, `contact.opened`, `contact.closed`, +`button.pressed`. Unmatched payloads classify as `device.shadow` — you can match that catch-all too. @@ -221,7 +240,28 @@ too. | Keyword | Meaning | Status | |-----------------|---------------------------------------------------------------|--------| | `time_between` | `[HH:MM, HH:MM]` local-time window, `start > end` → overnight | active | -| `device_state` | `{ device, field, op, value }` read device status inline | parsed; reports as `condition-unsupported` until E3 | +| `device_state` | `{ device, field, op, value }` read device status inline | active | +| `all` | AND-join multiple sub-conditions | active | +| `any` | OR-join multiple sub-conditions | active | +| `not` | Negate a sub-condition | active | +| `llm` | AI judgement — prompt an LLM before firing (see below) | active | + +**LLM condition fields:** + +```yaml +conditions: + - llm: + prompt: "Is the temperature above normal comfort range?" + provider: auto # auto | openai | anthropic + timeout_ms: 5000 # 500–10000 (default 5000) + cache_ttl: 5m # none | \d+[smh] (default 5m) + recent_events: 5 # 0–20 (default 5) — recent events included in prompt + budget: + max_calls_per_hour: 10 # per-condition limit (default 10) + on_error: fail # fail | pass | skip (default fail) +``` + +Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY`. `rules lint` flags misconfigured LLM conditions. Global LLM budget can be set via `automation.llm_budget.max_calls_per_hour` (default 60). **Destructive verbs are refused upstream.** The v0.2 validator rejects `lock`, `unlock`, `deleteWebhook`, `deleteScene`, @@ -277,10 +317,11 @@ Exit codes: | Code | Meaning | |---|---| -| 0 | File is valid and matches schema v0.1 | -| 1 | File is missing | -| 2 | YAML is malformed (parse error, with line/col) | -| 3 | Schema violation (line-accurate error with hint) | +| 0 | File is valid and matches schema v0.2 | +| 1 | Schema violation (line-accurate error with hint) | +| 2 | File is missing | +| 3 | YAML is malformed (parse error, with line/col) | +| 4 | Internal error | Every non-zero exit prints a compiler-style block: @@ -297,7 +338,7 @@ For machine consumption, pass `--json`. The envelope is the standard ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "error": { "kind": "usage", "message": "lowercase deviceId at policy.yaml:12:14", @@ -316,9 +357,10 @@ For machine consumption, pass `--json`. The envelope is the standard | Error | Trigger | Fix | |---|---|---| -| `missing version` | Top-level `version` is absent | Add `version: "0.1"` | -| `wrong version` | `version` is anything but `"0.1"` | Run `switchbot policy migrate` | -| `lowercase deviceId` | `aliases` value isn't UPPERCASE | Uppercase the ID (it is in `devices list`) | +| `missing version` | Top-level `version` is absent | Add `version: "0.2"` | +| `unsupported version` | `version` is not `"0.1"` or `"0.2"` | Check spelling; run `switchbot policy migrate` to upgrade from v0.1 | +| `wrong version` | `version: "0.1"` on a CLI that requires v0.2 | Run `switchbot policy migrate` | +| `lowercase deviceId` | `aliases` value doesn't match the accepted patterns | Copy the exact ID from `devices list` | | `destructive in never_confirm` | `lock`/`unlock`/etc in `confirmations.never_confirm` | Remove it; intentional by design | | `quiet_hours.start without end` | Only one of the two times is set | Set both, or remove the block | | `invalid retention` | `audit.retention` isn't `never` / `Nd` / `Nw` / `Nm` | Use one of the documented formats | @@ -331,19 +373,24 @@ machine-readable `rule` field so tooling can suggest fixes. ## Migrating between schema versions -v0.1 is the only published schema today. v0.2 (Phase 4) will add a -structured `rules[]` definition under `automation`. When it ships, -`switchbot policy migrate` will: +v0.2 is the current required schema. If you have a v0.1 file from an +earlier release, upgrade it: + +```bash +switchbot policy migrate # in-place upgrade, preserves comments +``` + +`policy migrate`: -1. Detect your current `version` field. -2. Apply additive changes only (new optional fields, tighter types on +1. Detects your current `version` field. +2. Applies additive changes only (new optional fields, tighter types on reserved blocks). -3. Rewrite the file with the new `version` constant. -4. Refuse to migrate if any user edits conflict, and explain what - conflicts. +3. Rewrites the file with the new `version` constant. +4. Refuses to migrate if any user edits conflict, and explains what + conflicts (exit code 7). -Until then, `policy migrate` is a no-op that verifies the file is -already current. +After migrating, run `switchbot policy validate` to confirm the file is +valid before using the rules engine. --- diff --git a/docs/schema-versioning.md b/docs/schema-versioning.md index 9a9f0c2..b0e0e1c 100644 --- a/docs/schema-versioning.md +++ b/docs/schema-versioning.md @@ -15,24 +15,24 @@ The CLI emits structured JSON responses wrapped in a top-level envelope that car Every JSON response is one of: ```json -{ "schemaVersion": "1.1", "data": { ... } } +{ "schemaVersion": "1.2", "data": { ... } } ``` ```json -{ "schemaVersion": "1.1", "error": { "code": 1, "kind": "...", "message": "..." } } +{ "schemaVersion": "1.2", "error": { "code": 1, "kind": "...", "message": "..." } } ``` The payload your integration cares about is always nested under `data` (success) or `error` (failure). `schemaVersion` describes the *payload shape*, not the CLI version — the envelope itself is the structural signal introduced in CLI 2.0. ### Historical nested location: `batch.summary.schemaVersion` -Before the top-level envelope existed, the `batch` command nested `schemaVersion` inside `summary`. That nested field is retained for back-compat — both of the following are set, and both equal `"1.1"`: +Before the top-level envelope existed, the `batch` command nested `schemaVersion` inside `summary`. That nested field is retained for back-compat — both of the following are set, and both equal `"1.2"`: ```json { - "schemaVersion": "1.1", + "schemaVersion": "1.2", "data": { - "summary": { "schemaVersion": "1.1", "total": 3, "ok": 2, "error": 1, "skipped": 0 }, + "summary": { "schemaVersion": "1.2", "total": 3, "ok": 2, "error": 1, "skipped": 0 }, "succeeded": [ ... ], "failed": [ ... ] } @@ -43,6 +43,11 @@ Prefer the top-level `schemaVersion`. The nested copy may be removed in a future ## Current Versions +- **v3.4.0**: schemaVersion "1.2" + - `catalog show --json`: `data` is now always an array (was bare object for single-type queries) + - `devices commands --json`: same change — `data` is always an array + - `fetchedAt` field renamed from `_fetchedAt` in `devices status` JSON output + - **v2.0.0**: schemaVersion "1.1" inside a new top-level `{schemaVersion, data|error}` envelope - Every `--json` response now has a top-level `schemaVersion` (previously only `batch.summary` had it) - Payload lives under `data` for success, `error` for failure @@ -87,6 +92,6 @@ Prefer the top-level `schemaVersion`. The nested copy may be removed in a future Some tools allow pinning to exact schema versions. We recommend against this for `schemaVersion`, since: - The CLI rarely ships breaking changes - Pinning to `"1"` means you stay on 1.0-1.9x even when security fixes land in 1.5+ -- Pinning to `"1.1"` works until a future v2 of the payload shape, at which point you'd need to update anyway +- Pinning to `"1.2"` works until a future v2 of the payload shape, at which point you'd need to update anyway Instead, test your integration against the current release and trust the semantic versioning signal. diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 4e854a9..11599c6 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -136,7 +136,7 @@ Examples: throw new UsageError(`"${match.type}" exists in the effective catalog but not in source "${source}".`); } if (isJsonMode()) { - printJson(picked); + printJson([picked]); return; } renderEntry(picked); diff --git a/src/commands/daemon.ts b/src/commands/daemon.ts index 412a3e5..2dc4828 100644 --- a/src/commands/daemon.ts +++ b/src/commands/daemon.ts @@ -212,7 +212,9 @@ The daemon reads the same policy file as \`switchbot rules run\`. } const thisFile = fileURLToPath(import.meta.url); - const cliEntry = path.resolve(path.dirname(thisFile), '..', 'index.js'); + const cliEntry = path.basename(thisFile) === 'index.js' + ? thisFile + : path.resolve(path.dirname(thisFile), '..', 'index.js'); const args = ['rules', 'run']; if (opts.policy) args.push(opts.policy); diff --git a/src/commands/devices.ts b/src/commands/devices.ts index f86a9bb..45f880c 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -327,7 +327,7 @@ Examples: const fetchedAt = new Date().toISOString(); const batch = results.map((r, i) => r.status === 'fulfilled' - ? { deviceId: ids[i], ok: true, _fetchedAt: fetchedAt, ...annotateStatusPayload(ids[i], r.value as Record) } + ? { deviceId: ids[i], ok: true, fetchedAt: fetchedAt, ...annotateStatusPayload(ids[i], r.value as Record) } : { deviceId: ids[i], ok: false, error: (r.reason as Error)?.message ?? String(r.reason) }, ); const batchFmt = resolveFormat(); @@ -340,7 +340,7 @@ Examples: } else { const rawFields = resolveFields(); for (const entry of batch) { - const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry as Record; + const { deviceId, ok, error, fetchedAt: ts, ...status } = entry as Record; console.log(`\n─── ${String(deviceId)} ───`); if (!ok) { console.error(` error: ${String(error)}`); @@ -371,12 +371,12 @@ Examples: const fmt = resolveFormat(); if (fmt === 'json' && process.argv.includes('--json')) { - printJson({ ...(body as object), _fetchedAt: fetchedAt }); + printJson({ ...(body as object), fetchedAt: fetchedAt }); return; } if (fmt !== 'table') { - const statusWithTs = { ...(body as Record), _fetchedAt: fetchedAt }; + const statusWithTs = { ...(body as Record), fetchedAt: fetchedAt }; const allHeaders = Object.keys(statusWithTs); const allRows = [Object.values(statusWithTs) as unknown[]]; const rawFields = resolveFields(); @@ -777,7 +777,7 @@ Examples: const joinedMatch = findCatalogEntry(joined); if (joinedMatch && !Array.isArray(joinedMatch)) { if (isJsonMode()) { - printJson(normalizeCatalogForJson(joinedMatch)); + printJson([normalizeCatalogForJson(joinedMatch)]); } else { renderCatalogEntry(joinedMatch); } diff --git a/src/commands/events.ts b/src/commands/events.ts index 16b5b28..12a3b4d 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -262,7 +262,7 @@ Examples: : null; // P7: streaming JSON contract — first line under --json is the // stream header (webhook events arrive via push cadence). - if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push' }); + if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push', schemaVersion: EVENTS_SCHEMA_VERSION }); await new Promise((resolve, reject) => { let server: http.Server | null = null; try { @@ -459,7 +459,7 @@ Examples: // P7: streaming JSON contract — first line under --json is the stream // header (mqtt events arrive via push cadence). Must emit BEFORE // __session_start so header is always the very first line. - if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push' }); + if (isJsonMode()) emitStreamHeader({ eventKind: 'event', cadence: 'push', schemaVersion: EVENTS_SCHEMA_VERSION }); // Emit a __session_start envelope immediately (before any credential // fetch) so JSON consumers can distinguish "connecting" from "never // connected" even when mqtt-tail exits before the broker connects. diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 417103a..0c50f5d 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -3,6 +3,7 @@ import { intArg, stringArg, enumArg } from '../utils/arg-parsers.js'; import { handleError, isJsonMode, printJson, UsageError, exitWithError } from '../utils/output.js'; import { getCachedDevice } from '../devices/cache.js'; import { executeCommand, isDestructiveCommand, getDestructiveReason } from '../lib/devices.js'; +import { findCatalogEntry } from '../devices/catalog.js'; import { isDryRun } from '../utils/flags.js'; import { resolveDeviceId, ALL_STRATEGIES, type NameResolveStrategy } from '../utils/name-resolver.js'; import { DryRunSignal } from '../api/client.js'; @@ -11,6 +12,10 @@ import { buildCurtainSetPosition, buildBlindTiltSetPosition, buildRelaySetMode, + buildBrightnessSet, + buildColorSet, + buildColorTemperatureSet, + isLightingCommandSupported, } from '../devices/param-validator.js'; // ---- Registration ---------------------------------------------------------- @@ -20,7 +25,7 @@ export function registerExpandCommand(devices: Command): void { .command('expand') .description('Send a command with semantic flags instead of raw positional parameters') .argument('[deviceId]', 'Target device ID from "devices list" (or use --name)') - .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2)') + .argument('[command]', 'Command name: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch 2), setBrightness/setColor/setColorTemperature (lighting)') .option('--name ', 'Resolve device by fuzzy name instead of deviceId', stringArg('--name')) .option('--name-strategy ', `Name match strategy: ${ALL_STRATEGIES.join('|')} (default: require-unique)`, stringArg('--name-strategy')) .option('--name-type ', 'Narrow --name by device type (e.g. "Curtain", "Air Conditioner")', stringArg('--name-type')) @@ -34,6 +39,9 @@ export function registerExpandCommand(devices: Command): void { .option('--direction ', 'Blind Tilt setPosition: up|down', stringArg('--direction')) .option('--angle ', 'Blind Tilt setPosition: 0-100 (0=closed, 100=open)', intArg('--angle', { min: 0, max: 100 })) .option('--channel ', 'Relay Switch 2 setMode: channel 1 or 2', intArg('--channel', { min: 1, max: 2 })) + .option('--brightness ', 'setBrightness: 1-100 percent', intArg('--brightness', { min: 1, max: 100 })) + .option('--color ', 'setColor: R:G:B, #RRGGBB, or named color (red, blue, etc.)', stringArg('--color')) + .option('--color-temp ', 'setColorTemperature: 2700-6500 Kelvin', intArg('--color-temp', { min: 2700, max: 6500 })) .option('--yes', 'Confirm destructive commands') .addHelpText('after', ` Translates semantic flags into the wire parameter format, then sends the command. @@ -56,12 +64,25 @@ Supported expansions: --channel 1 --mode edge → "1;1" --mode values: toggle (0) | edge (1) | detached (2) | momentary (3) + Color Bulb / Strip Light / Ceiling Light — setBrightness + --brightness 80 → "80" + + Color Bulb / Strip Light / Floor Lamp — setColor + --color "255:0:0" → "255:0:0" + --color "#FF0000" → "255:0:0" + --color red → "255:0:0" + + Color Bulb / Strip Light / Ceiling Light — setColorTemperature + --color-temp 4000 → "4000" + Examples: - $ switchbot devices expand setAll --temp 26 --mode cool --fan low --power on - $ switchbot devices expand setPosition --position 50 --mode silent - $ switchbot devices expand setPosition --direction up --angle 50 - $ switchbot devices expand setMode --channel 1 --mode edge - $ switchbot devices expand setAll --temp 22 --mode heat --fan auto --power on --dry-run + $ switchbot devices expand setAll --temp 26 --mode cool --fan low --power on + $ switchbot devices expand setPosition --position 50 --mode silent + $ switchbot devices expand setPosition --direction up --angle 50 + $ switchbot devices expand setMode --channel 1 --mode edge + $ switchbot devices expand setBrightness --brightness 80 + $ switchbot devices expand setColor --color "#FF0000" + $ switchbot devices expand setColorTemperature --color-temp 4000 $ switchbot devices expand --name "Living Room AC" setAll --temp 26 --mode cool --fan low --power on `) .action(async ( @@ -75,7 +96,8 @@ Examples: nameRoom?: string; temp?: string; mode?: string; fan?: string; power?: string; position?: string; direction?: string; angle?: string; - channel?: string; yes?: boolean; + channel?: string; brightness?: string; color?: string; + colorTemp?: string; yes?: boolean; } ) => { let deviceId = ''; @@ -96,7 +118,7 @@ Examples: category: options.nameCategory, room: options.nameRoom, }); - if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode).'); + if (!effectiveCommand) throw new UsageError('A command argument is required (setAll, setPosition, setMode, setBrightness, setColor, setColorTemperature).'); command = effectiveCommand; const cached = getCachedDevice(deviceId); @@ -105,6 +127,16 @@ Examples: let parameter: string; if (command === 'setAll') { + if (!cached) { + throw new UsageError( + `Device ${deviceId} is not in the local cache — run 'switchbot devices list' first so 'expand' can verify this is an Air Conditioner.` + ); + } + if (deviceType !== 'Air Conditioner') { + throw new UsageError( + `"setAll" is only supported on Air Conditioner devices, but "${cached.type}" was found.` + ); + } parameter = buildAcSetAll(options); } else if (command === 'setPosition') { if (!cached) { @@ -112,15 +144,61 @@ Examples: `Device ${deviceId} is not in the local cache — run 'switchbot devices list' first so 'expand' knows whether this is a Curtain or a Blind Tilt.` ); } + const positionTypes = ['Curtain', 'Curtain 3', 'Roller Shade', 'Blind Tilt']; + if (!positionTypes.some(t => deviceType.startsWith(t))) { + throw new UsageError( + `"setPosition" is only supported on Curtain, Roller Shade, and Blind Tilt devices, but "${cached.type}" was found.` + ); + } const isBlind = deviceType.startsWith('Blind Tilt'); - parameter = isBlind - ? buildBlindTiltSetPosition(options) - : buildCurtainSetPosition(options); + const isRollerShade = deviceType.startsWith('Roller Shade'); + if (isBlind) { + parameter = buildBlindTiltSetPosition(options); + } else if (isRollerShade) { + if (!options.position) throw new UsageError('--position is required (0-100)'); + parameter = options.position; + } else { + parameter = buildCurtainSetPosition(options); + } } else if (command === 'setMode' && deviceType.startsWith('Relay Switch')) { parameter = buildRelaySetMode(options); + } else if (command === 'setBrightness' || command === 'setColor' || command === 'setColorTemperature') { + if (!cached) { + throw new UsageError( + `Device "${deviceId}" is not in the local cache — run 'switchbot devices list' first so 'expand' can verify this device supports ${command}.` + ); + } + const catalogResult = findCatalogEntry(cached.type); + const catalogEntry = Array.isArray(catalogResult) ? catalogResult[0] : catalogResult; + const supportedHint = command === 'setColor' + ? 'Color Bulb, Strip Light, Floor Lamp, and similar RGB lighting devices' + : 'Color Bulb, Strip Light, Ceiling Light, Floor Lamp, and similar lighting devices'; + if (catalogEntry !== null) { + // Device is in catalog — catalog is authoritative, no heuristic fallback + if (!catalogEntry.commands.some((c: { command: string }) => c.command === command)) { + throw new UsageError( + `Device type "${cached.type}" does not support ${command}. Supported on: ${supportedHint}.` + ); + } + } else { + // Device not in catalog — fall back to param-validator whitelist + if (!isLightingCommandSupported(cached.type, command)) { + throw new UsageError( + `Device type "${cached.type}" does not support ${command}. Supported on: ${supportedHint}.` + ); + } + } + if (command === 'setBrightness') { + parameter = buildBrightnessSet(options); + } else if (command === 'setColor') { + parameter = buildColorSet(options); + } else { + parameter = buildColorTemperatureSet(options); + } } else { throw new UsageError( `'expand' does not support "${command}" for device type "${deviceType || 'unknown'}". ` + + `Supported: setAll (AC), setPosition (Curtain/Blind Tilt), setMode (Relay Switch), setBrightness/setColor/setColorTemperature (lighting). ` + `Use 'switchbot devices command' to send raw parameters instead.` ); } diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d6101d9..c1886d4 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -2188,13 +2188,38 @@ export function listRegisteredTools(server: McpServer): string[] { return Object.keys(internal._registeredTools).sort(); } +interface ToolDirectoryEntry { + name: string; + description?: string; + inputSchema?: Record; +} + +function listRegisteredToolsWithMeta(server: McpServer): ToolDirectoryEntry[] { + const internal = server as unknown as { _registeredTools?: Record }; + if (!internal._registeredTools) return []; + return Object.entries(internal._registeredTools) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, reg]) => { + const entry: ToolDirectoryEntry = { name }; + if (reg.description) entry.description = reg.description; + if (reg.inputSchema) { + try { + entry.inputSchema = z.toJSONSchema(reg.inputSchema) as Record; + } catch { + // Fall back: emit the schema type name if conversion fails + } + } + return entry; + }); +} + function listRegisteredResources(): string[] { return ['switchbot://events']; } function printMcpToolDirectory(): void { const server = createSwitchBotMcpServer(); - const tools = listRegisteredTools(server).map((name) => ({ name })); + const tools = listRegisteredToolsWithMeta(server); const resources = listRegisteredResources().map((uri) => ({ uri })); if (isJsonMode()) { printJson({ tools, resources }); @@ -2202,7 +2227,8 @@ function printMcpToolDirectory(): void { } console.log('Tools:'); for (const tool of tools) { - console.log(` ${tool.name}`); + const desc = tool.description ? ` — ${tool.description.slice(0, 80)}` : ''; + console.log(` ${tool.name}${desc}`); } console.log(''); console.log('Resources:'); @@ -2217,7 +2243,7 @@ export function registerMcpCommand(program: Command): void { .command('mcp') .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools') .addHelpText('after', ` - The MCP server exposes twenty-one tools: + The MCP server exposes twenty-four tools: - list_devices fetch all physical + IR devices - get_device_status live status for a physical device - send_command control a device (destructive commands need confirm:true) @@ -2240,6 +2266,8 @@ export function registerMcpCommand(program: Command): void { - audit_stats aggregate audit counts by kind/result/device/rule - rule_notifications query rule notify action delivery history - rules_suggest draft an automation rule YAML from intent (heuristic, no LLM) + - rules_explain show why a rule evaluation fired or was blocked + - rules_simulate simulate a rule against historical events - policy_add_rule append a rule into automation.rules[] in policy.yaml Resource (read-only): diff --git a/src/commands/rules.ts b/src/commands/rules.ts index 4bb202f..32bd817 100644 --- a/src/commands/rules.ts +++ b/src/commands/rules.ts @@ -209,13 +209,12 @@ function registerRun(rules: Command): void { if (!loaded) return; if (loaded.automation?.enabled !== true) { - const msg = 'automation.enabled is not true — nothing to run.'; - if (isJsonMode()) { - printJson({ kind: 'control', controlKind: 'disabled', message: msg }); - } else { - console.error(msg); - } - process.exit(0); + exitWithError({ + code: 1, + kind: 'runtime', + message: 'automation.enabled is not true — set it to true in your policy file to start the daemon.', + hint: 'Set automation.enabled: true in your policy file, then re-run.', + }); } const lint = lintRules(loaded.automation); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index ea0e774..77d56c5 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -164,7 +164,7 @@ function runSchemaExport(options: { type?: string; types?: string; role?: string payload.resources = RESOURCE_CATALOG; payload.cliAddedFields = [ { - field: '_fetchedAt', + field: 'fetchedAt', appliesTo: ['devices status', 'devices describe'], type: 'string (ISO-8601)', description: @@ -256,7 +256,7 @@ Common top-level fields: schemaVersion CLI schema version (stable for agent contracts) data.version Catalog schema version data.types Array of SchemaEntry (or CompactSchemaEntry with --compact) - data._fetchedAt CLI-added; present on live-query responses ('devices status'), + data.fetchedAt CLI-added; present on live-query responses ('devices status'), not on this offline export. Examples: diff --git a/src/devices/param-validator.ts b/src/devices/param-validator.ts index ed8572b..ca1ca91 100644 --- a/src/devices/param-validator.ts +++ b/src/devices/param-validator.ts @@ -82,6 +82,29 @@ export function buildRelaySetMode(opts: { return `${ch};${modeInt}`; } +export function buildBrightnessSet(opts: { brightness?: string }): string { + if (!opts.brightness) throw new UsageError('--brightness is required (1-100)'); + const b = parseInt(opts.brightness, 10); + if (!Number.isFinite(b) || b < 1 || b > 100) { + throw new UsageError(`--brightness must be an integer between 1 and 100 (got "${opts.brightness}")`); + } + return String(b); +} + +export function buildColorSet(opts: { color?: string }): string { + if (!opts.color) throw new UsageError('--color is required (e.g. "255:0:0", "#FF0000", "red")'); + const result = validateSetColor(opts.color); + if (!result.ok) throw new UsageError(result.error); + return result.normalized ?? opts.color; +} + +export function buildColorTemperatureSet(opts: { colorTemp?: string }): string { + if (!opts.colorTemp) throw new UsageError('--color-temp is required (2700-6500)'); + const result = validateSetColorTemperature(opts.colorTemp); + if (!result.ok) throw new UsageError(result.error); + return result.normalized ?? opts.colorTemp; +} + // ---- Raw-parameter validator (used by `devices command`) ------------------ export type ValidateResult = @@ -123,7 +146,7 @@ export function validateParameter( if (command === 'setColor' && isColorDevice(deviceType)) { return validateSetColor(raw); } - if (command === 'setColorTemperature' && isColorDevice(deviceType)) { + if (command === 'setColorTemperature' && isBrightnessDevice(deviceType)) { return validateSetColorTemperature(raw); } @@ -149,14 +172,18 @@ function isColorDevice(deviceType: string): boolean { deviceType === 'Color Bulb' || deviceType === 'Strip Light' || deviceType === 'Strip Light 3' || - deviceType === 'Ceiling Light' || - deviceType === 'Ceiling Light Pro' || deviceType === 'Floor Lamp' || deviceType === 'Light Strip' || deviceType === 'Fill Light' ); } +export function isLightingCommandSupported(deviceType: string, command: string): boolean { + if (command === 'setBrightness' || command === 'setColorTemperature') return isBrightnessDevice(deviceType); + if (command === 'setColor') return isColorDevice(deviceType); + return false; +} + function validateSetBrightness(raw: string | undefined): ValidateResult { if (raw === undefined || raw === '' || raw === 'default') { return { diff --git a/src/index.ts b/src/index.ts index 1759a7e..d10303c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -208,17 +208,38 @@ try { // Mirror the root mapping so all usage errors surface as exit 2. if (err instanceof CommanderError) { if (err.code === 'commander.helpDisplayed') { + const helpRequested = process.argv.includes('--help') || process.argv.includes('-h') || process.argv.includes('help'); + if (helpRequested) { + if (isJsonMode()) { + const target = resolveTargetCommand(program, process.argv.slice(2)); + printJson(commandToJson(target, { includeIdentity: target === program })); + } + process.exit(0); + } if (isJsonMode()) { const target = resolveTargetCommand(program, process.argv.slice(2)); - printJson(commandToJson(target, { includeIdentity: target === program })); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + const usefulMessage = subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : err.message; + emitJsonError({ code: 2, kind: 'usage', message: usefulMessage }); } - process.exit(0); + process.exit(2); } if (err.code === 'commander.version') { process.exit(0); } if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: err.message }); + const errorMessage = err.code === 'commander.help' + ? (() => { + const target = resolveTargetCommand(program, process.argv.slice(2)); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + return subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : err.message; + })() + : err.message; + emitJsonError({ code: 2, kind: 'usage', message: errorMessage }); } process.exit(2); } diff --git a/src/logger.ts b/src/logger.ts index cdb6811..6c829ee 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -6,11 +6,13 @@ const logFormat = process.env.LOG_FORMAT || 'json'; const pinoConfig = { level: logLevel, transport: logFormat === 'pretty' - ? { target: 'pino-pretty' } + ? { target: 'pino-pretty', options: { destination: 2 } } : undefined, }; -export const log = pino(pinoConfig); +export const log = logFormat === 'pretty' + ? pino(pinoConfig) + : pino(pinoConfig, pino.destination(2)); export function setLogLevel(level: string): void { log.level = level; diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 3e4549b..1c2d3d7 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -81,11 +81,22 @@ export function commandToJson(cmd: Command, opts: CommandToJsonOptions = {}): Co return out; } -/** Walk argv tokens (skipping flags) to find the deepest matching subcommand. */ +/** Walk argv tokens to find the deepest matching subcommand, skipping flags and their values. */ export function resolveTargetCommand(root: Command, argv: string[]): Command { let cmd = root; + const rootOptions = root.options as import('commander').Option[]; + let consumeNext = false; for (const token of argv) { - if (token.startsWith('-')) continue; + if (consumeNext) { consumeNext = false; continue; } + if (token.startsWith('-')) { + if (!token.includes('=')) { + const localOpts = cmd.options as import('commander').Option[]; + const opt = localOpts.find((o) => o.short === token || o.long === token) + || rootOptions.find((o) => o.short === token || o.long === token); + if (opt && (opt.required || opt.optional)) consumeNext = true; + } + continue; + } const sub = cmd.commands.find( (c) => c.name() === token || (c.aliases() as string[]).includes(token) ); diff --git a/src/utils/output.ts b/src/utils/output.ts index 335b2fa..70ff33e 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -4,7 +4,7 @@ import { ApiError, DryRunSignal } from '../api/client.js'; import { getFormat, getTableStyle, type TableStyle } from './flags.js'; -export const SCHEMA_VERSION = '1.1'; +export const SCHEMA_VERSION = '1.2'; export function isJsonMode(): boolean { return process.argv.includes('--json') || getFormat() === 'json'; @@ -46,10 +46,11 @@ export function emitJsonError(errorPayload: Record): void { export function emitStreamHeader(opts: { eventKind: 'tick' | 'event'; cadence: 'poll' | 'push'; + schemaVersion?: string; }): void { console.log( JSON.stringify({ - schemaVersion: SCHEMA_VERSION, + schemaVersion: opts.schemaVersion ?? SCHEMA_VERSION, stream: true, eventKind: opts.eventKind, cadence: opts.cadence, diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index b61ea3b..ea8241e 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -155,7 +155,7 @@ describe('cache clear', () => { const result = await runCli(registerCacheCommand, ['--json', 'cache', 'clear', '--key', 'list']); expect(result.exitCode).toBeNull(); const parsed = JSON.parse(result.stdout.join('\n')); - expect(parsed).toEqual({ schemaVersion: '1.1', data: { cleared: ['list'] } }); + expect(parsed).toEqual({ schemaVersion: '1.2', data: { cleared: ['list'] } }); }); it('is a no-op when files do not exist', async () => { diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts index 4605fbe..be91cea 100644 --- a/tests/commands/catalog.test.ts +++ b/tests/commands/catalog.test.ts @@ -159,11 +159,12 @@ describe('catalog show', () => { expect(data.find((e) => e.type === 'Bot')).toBeDefined(); }); - it('emits a single-entry JSON object when a type is given', async () => { + it('emits a single-entry JSON array when a type is given', async () => { const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show', 'Bot']); const parsed = JSON.parse(stdout.join('\n')) as Record; - const data = expectJsonEnvelopeContainingKeys(parsed, ['type', 'category', 'description', 'role', 'commands', 'statusFields']); - expect(data.type).toBe('Bot'); + const arr = expectJsonArrayEnvelope(parsed); + expect(arr).toHaveLength(1); + expect((arr[0] as Record).type).toBe('Bot'); }); }); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index a132748..ab41000 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -717,10 +717,10 @@ describe('devices command', () => { ]); const parsed = JSON.parse(res.stdout.join('\n')); expect(Array.isArray(parsed.data)).toBe(true); - // _fetchedAt is added by the CLI; verify other fields are present + // fetchedAt is added by the CLI; verify other fields are present expect(parsed.data[0].power).toBe('off'); expect(parsed.data[0].battery).toBe(50); - expect(parsed.data[0]._fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(parsed.data[0].fetchedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); it('serializes nested objects to JSON strings in tsv output', async () => { @@ -774,7 +774,7 @@ describe('devices command', () => { 'devices', 'status', 'DEV3', '--format', 'tsv', ]); const lines = res.stdout.join('\n').split('\n'); - // null maps to empty string in cellToString; _fetchedAt column is also present + // null maps to empty string in cellToString; fetchedAt column is also present expect(lines[1]).toMatch(/^on\t\t/); }); @@ -2565,7 +2565,7 @@ describe('devices command', () => { const out = res.stdout.join('\n'); expect(out).toBeTruthy(); const parsed = JSON.parse(out); - expect(parsed.schemaVersion).toBe('1.1'); + expect(parsed.schemaVersion).toBe('1.2'); expect(parsed.data.dryRun).toBe(true); expect(parsed.data.wouldSend.deviceId).toBe(DRY_ID); expect(parsed.data.wouldSend.command).toBe('turnOff'); @@ -2592,7 +2592,7 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'list', '--help']); expect(res.exitCode).toBe(0); const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.schemaVersion).toBe('1.1'); + expect(parsed.schemaVersion).toBe('1.2'); expect(parsed.data.name).toBe('list'); expect(Array.isArray(parsed.data.options)).toBe(true); expect(Array.isArray(parsed.data.arguments)).toBe(true); @@ -2616,7 +2616,7 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'commands', 'Bot']); expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout.join('\n')); - const cmds: Array<{ safetyTier?: string }> = parsed.data.commands; + const cmds: Array<{ safetyTier?: string }> = parsed.data[0].commands; expect(cmds.length).toBeGreaterThan(0); for (const c of cmds) { expect(typeof c.safetyTier).toBe('string'); @@ -2627,7 +2627,7 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'commands', 'Smart Lock']); expect(res.exitCode).toBeNull(); const parsed = JSON.parse(res.stdout.join('\n')); - const cmds: Array<{ command: string; safetyTier: string }> = parsed.data.commands; + const cmds: Array<{ command: string; safetyTier: string }> = parsed.data[0].commands; const unlock = cmds.find((c) => c.command === 'unlock'); const lock = cmds.find((c) => c.command === 'lock'); expect(unlock?.safetyTier).toBe('destructive'); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 3aa9cda..bc0e357 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -368,8 +368,7 @@ describe('doctor command', () => { const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'bogus']); expect(res.exitCode).toBe(2); const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); - expect(payload.schemaVersion).toBe('1.1'); - expect(payload.error.message).toMatch(/Unknown check name/); + expect(payload.schemaVersion).toBe('1.2'); expect(payload.error.message).toMatch(/bogus/); expect(payload.error.message).toMatch(/Valid:/); }); diff --git a/tests/commands/error-envelope.test.ts b/tests/commands/error-envelope.test.ts index 8542c08..74b5c01 100644 --- a/tests/commands/error-envelope.test.ts +++ b/tests/commands/error-envelope.test.ts @@ -19,6 +19,8 @@ import { exitWithError, SCHEMA_VERSION, } from '../../src/utils/output.js'; +import { runCli } from '../helpers/cli.js'; +import { registerCacheCommand } from '../../src/commands/cache.js'; describe('error envelope contract (P5)', () => { let stdoutSpy: ReturnType; @@ -181,6 +183,28 @@ describe('error envelope contract (P5)', () => { }); }); +describe('parent command without subcommand (--json)', () => { + it('exits 2 and emits structured error with useful message', async () => { + const res = await runCli(registerCacheCommand, ['--json', 'cache']); + expect(res.exitCode).toBe(2); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.schemaVersion).toBe(SCHEMA_VERSION); + expect(parsed.error.code).toBe(2); + expect(parsed.error.kind).toBe('usage'); + expect(parsed.error.message).toMatch(/cache.*subcommand.*required/i); + expect(parsed.error.message).toContain('Available:'); + }); + + it('exits 0 and emits help JSON when --help is passed', async () => { + const res = await runCli(registerCacheCommand, ['--json', 'cache', '--help']); + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.schemaVersion).toBe(SCHEMA_VERSION); + expect(parsed.data.name).toBe('cache'); + expect(Array.isArray(parsed.data.subcommands)).toBe(true); + }); +}); + /** * Silence unused-vars — keep Command import available for future command-level * smoke tests under this suite. diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index 42c849c..8f107de 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -544,7 +544,7 @@ describe('events mqtt-tail', () => { eventKind: string; cadence: string; }; - expectStreamHeaderShape(header as Record, 'event', 'push'); + expectStreamHeaderShape(header as Record, 'event', 'push', '1'); }); it('P7: mqtt-tail JSON event lines keep the unified envelope and payloadVersion fields', async () => { diff --git a/tests/commands/expand.test.ts b/tests/commands/expand.test.ts index 69dc5b0..35f65d2 100644 --- a/tests/commands/expand.test.ts +++ b/tests/commands/expand.test.ts @@ -30,12 +30,22 @@ const AC_ID = 'AC-001'; const CURTAIN_ID = 'CURTAIN-001'; const BLIND_ID = 'BLIND-001'; const RELAY_ID = 'RELAY-001'; +const BULB_ID = 'BULB-001'; +const BOT_ID = 'BOT-001'; +const LAMP_ID = 'LAMP-001'; +const STRIP_ID = 'STRIP-001'; +const CEILING_ID = 'CEILING-001'; const sampleBody = { deviceList: [ { deviceId: CURTAIN_ID, deviceName: 'Living Curtain', deviceType: 'Curtain', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: BLIND_ID, deviceName: 'Bedroom Blind', deviceType: 'Blind Tilt', hubDeviceId: 'H1', enableCloudService: true }, { deviceId: RELAY_ID, deviceName: 'Kitchen Switch', deviceType: 'Relay Switch 2PM', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: BULB_ID, deviceName: 'Bedroom Bulb', deviceType: 'Color Bulb', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: BOT_ID, deviceName: 'Door Bot', deviceType: 'Bot', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: LAMP_ID, deviceName: 'Desk Lamp', deviceType: 'Floor Lamp', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: STRIP_ID, deviceName: 'TV Strip', deviceType: 'Light Strip', hubDeviceId: 'H1', enableCloudService: true }, + { deviceId: CEILING_ID, deviceName: 'Living Ceiling', deviceType: 'Ceiling Light', hubDeviceId: 'H1', enableCloudService: true }, ], infraredRemoteList: [ { deviceId: AC_ID, deviceName: 'Living AC', remoteType: 'Air Conditioner', hubDeviceId: 'H1', controlType: 'Air Conditioner' }, @@ -242,4 +252,132 @@ describe('devices expand', () => { expect.objectContaining({ command: 'setPosition' }), ); }); + + it('setBrightness on Color Bulb sends correct parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setBrightness', '--brightness', '50', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${BULB_ID}/commands`, + { command: 'setBrightness', parameter: '50', commandType: 'command' }, + ); + }); + + it('setColor on Color Bulb sends correct parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setColor', '--color', '255:0:0', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${BULB_ID}/commands`, + { command: 'setColor', parameter: '255:0:0', commandType: 'command' }, + ); + }); + + it('setColorTemperature on Color Bulb sends correct parameter', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setColorTemperature', '--color-temp', '4000', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${BULB_ID}/commands`, + { command: 'setColorTemperature', parameter: '4000', commandType: 'command' }, + ); + }); + + it('setBrightness on unsupported type (Bot) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BOT_ID, 'setBrightness', '--brightness', '50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Bot.*does not support setBrightness/); + }); + + it('setColor on unsupported type (Curtain) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', CURTAIN_ID, 'setColor', '--color', '255:0:0', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Curtain.*does not support setColor/); + }); + + it('setBrightness on uncached device ID → UsageError asking to run devices list', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', 'UNKNOWN-999', 'setBrightness', '--brightness', '50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/not in the local cache/); + }); + + it('setBrightness on Color Bulb emits valid JSON envelope in --json mode', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BULB_ID, 'setBrightness', '--brightness', '75', '--json', + ]); + expect(res.exitCode).toBe(null); + const parsed = JSON.parse(res.stdout.join('\n')) as Record; + const data = parsed.data as Record; + expect(data.ok).toBe(true); + expect(data.command).toBe('setBrightness'); + expect(data.parameter).toBe('75'); + expect(data.deviceId).toBe(BULB_ID); + }); + + it('setBrightness on Floor Lamp (not in catalog, supported by validator) succeeds', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', LAMP_ID, 'setBrightness', '--brightness', '40', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${LAMP_ID}/commands`, + { command: 'setBrightness', parameter: '40', commandType: 'command' }, + ); + }); + + it('setColor on Light Strip (not in catalog, supported by validator) succeeds', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', STRIP_ID, 'setColor', '--color', '0:255:0', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${STRIP_ID}/commands`, + { command: 'setColor', parameter: '0:255:0', commandType: 'command' }, + ); + }); + + it('setColor on Ceiling Light (in catalog, no RGB) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', CEILING_ID, 'setColor', '--color', '255:0:0', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Ceiling Light.*does not support setColor/); + }); + + it('setBrightness on Ceiling Light (in catalog, supports it) → succeeds', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', CEILING_ID, 'setBrightness', '--brightness', '60', + ]); + expect(res.exitCode).toBe(null); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${CEILING_ID}/commands`, + { command: 'setBrightness', parameter: '60', commandType: 'command' }, + ); + }); + + it('setAll on non-AC device (Bot) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BOT_ID, 'setAll', + '--temp', '26', '--mode', 'cool', '--fan', 'low', '--power', 'on', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/only supported on Air Conditioner/); + }); + + it('setPosition on non-Curtain/Blind device (Bot) → UsageError', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'expand', BOT_ID, 'setPosition', '--position', '50', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/only supported on Curtain/); + }); }); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 6f9b387..876d452 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -135,6 +135,10 @@ describe('mcp server', () => { expect(Object.keys(out.data)).toEqual(['tools', 'resources']); expect(Array.isArray(out.data.tools)).toBe(true); expect(out.data.tools.some((t: { name: string }) => t.name === 'list_devices')).toBe(true); + for (const tool of out.data.tools) { + expect(tool.description).toBeTypeOf('string'); + expect(tool.inputSchema).toBeDefined(); + } expect(Array.isArray(out.data.resources)).toBe(true); expect(out.data.resources.some((r: { uri: string }) => r.uri === 'switchbot://events')).toBe(true); }); diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts index 14f7a31..6eb6ab5 100644 --- a/tests/commands/quota.test.ts +++ b/tests/commands/quota.test.ts @@ -91,6 +91,6 @@ describe('quota command', () => { await seedQuota(); const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'reset']); expect(result.exitCode).toBeNull(); - expect(JSON.parse(result.stdout[0])).toEqual({ schemaVersion: '1.1', data: { reset: true } }); + expect(JSON.parse(result.stdout[0])).toEqual({ schemaVersion: '1.2', data: { reset: true } }); }); }); diff --git a/tests/commands/rules.test.ts b/tests/commands/rules.test.ts index 9017e1c..fbdefcf 100644 --- a/tests/commands/rules.test.ts +++ b/tests/commands/rules.test.ts @@ -238,7 +238,7 @@ describe('switchbot rules (commander surface)', () => { delete process.env.SWITCHBOT_SECRET; }); - it('exits 0 early when automation.enabled is false', async () => { + it('exits 1 early when automation.enabled is false', async () => { const p = path.join(tmpDir, 'policy.yaml'); fs.writeFileSync( p, @@ -253,9 +253,33 @@ describe('switchbot rules (commander surface)', () => { 'utf-8', ); const { stderr, exitCode } = await runCli(['rules', 'run', p]); - expect(exitCode).toBe(0); + expect(exitCode).toBe(1); expect(stderr.join('\n')).toContain('automation.enabled is not true'); }); + + it('--json: exits 1 with error envelope when automation.enabled is false', async () => { + const p = path.join(tmpDir, 'policy.yaml'); + fs.writeFileSync( + p, + v02Policy( + [ + 'automation:', + ' enabled: false', + ' rules: []', + '', + ].join('\n'), + ), + 'utf-8', + ); + const { stdout, exitCode } = await runCli(['--json', 'rules', 'run', p]); + expect(exitCode).toBe(1); + const parsed = JSON.parse(stdout.join('\n')); + expect(parsed.error).toBeDefined(); + expect(parsed.error.code).toBe(1); + expect(parsed.error.kind).toBe('runtime'); + expect(parsed.error.message).toContain('automation.enabled is not true'); + expect(parsed.data).toBeUndefined(); + }); }); describe('rules reload', () => { diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 2281c74..11e87e7 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -83,12 +83,37 @@ export async function runCli( // Mirror production exitOverride in src/index.ts: non-help/version // Commander errors surface as usage errors (exit 2). if (errAsCommander.code === 'commander.helpDisplayed') { - // Mirror production: emit JSON help when --json is in argv. + const helpRequested = argv.includes('--help') || argv.includes('-h'); + if (helpRequested) { + // Mirror production: emit JSON help when --json is in argv. + if (argv.includes('--json')) { + const target = resolveTargetCommand(program, argv); + stdout.push(JSON.stringify({ schemaVersion: '1.2', data: commandToJson(target) }, null, 2)); + } + exitCode = 0; + } else { + // Parent command called without a required subcommand — mirror production exit 2. + if (argv.includes('--json')) { + const target = resolveTargetCommand(program, argv); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + const msg = subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : '(outputHelp)'; + stdout.push(JSON.stringify({ schemaVersion: '1.2', error: { code: 2, kind: 'usage', message: msg } }, null, 2)); + } + exitCode = 2; + } + } else if (errAsCommander.code === 'commander.help') { + // Parent command invoked without a subcommand (Commander 12: 'commander.help'). if (argv.includes('--json')) { const target = resolveTargetCommand(program, argv); - stdout.push(JSON.stringify({ schemaVersion: '1.1', data: commandToJson(target) }, null, 2)); + const subNames = target.commands.map((c: Command) => c.name()).join(', '); + const msg = subNames + ? `${target.name()}: a subcommand is required. Available: ${subNames}` + : '(outputHelp)'; + stdout.push(JSON.stringify({ schemaVersion: '1.2', error: { code: 2, kind: 'usage', message: msg } }, null, 2)); } - exitCode = 0; + exitCode = 2; } else if (errAsCommander.code === 'commander.version') { exitCode = 0; } else { diff --git a/tests/helpers/contracts.ts b/tests/helpers/contracts.ts index bae2bf0..e17f2f9 100644 --- a/tests/helpers/contracts.ts +++ b/tests/helpers/contracts.ts @@ -30,8 +30,9 @@ export function expectStreamHeaderShape( header: Record, eventKind: 'tick' | 'event', cadence: 'poll' | 'push', + expectedVersion = '1.2', ): void { - expect(header.schemaVersion).toBe('1.1'); + expect(header.schemaVersion).toBe(expectedVersion); expect(header.stream).toBe(true); expect(header.eventKind).toBe(eventKind); expect(header.cadence).toBe(cadence); @@ -43,7 +44,7 @@ export function expectStreamJsonEnvelopeShape( dataKeys: string[], ): Record { expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); - expect(payload.schemaVersion).toBe('1.1'); + expect(payload.schemaVersion).toBe('1.2'); const data = payload.data as Record; expect(Object.keys(data)).toEqual(dataKeys); return data; @@ -54,7 +55,7 @@ export function expectStreamJsonEnvelopeContainingKeys( requiredDataKeys: string[], ): Record { expect(Object.keys(payload)).toEqual(['schemaVersion', 'data']); - expect(payload.schemaVersion).toBe('1.1'); + expect(payload.schemaVersion).toBe('1.2'); const data = payload.data as Record; expect(Object.keys(data)).toEqual(expect.arrayContaining(requiredDataKeys)); return data; diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts index 62a516e..1d0fdc0 100644 --- a/tests/utils/format.test.ts +++ b/tests/utils/format.test.ts @@ -132,7 +132,7 @@ describe('renderRows', () => { renderRows(headers, rows, 'json'); const parsed = JSON.parse(logOutput.join('\n')); expect(parsed).toEqual({ - schemaVersion: '1.1', + schemaVersion: '1.2', data: [ { deviceId: 'DEV1', name: 'Light', type: 'Bot' }, { deviceId: 'DEV2', name: 'Door', type: 'Smart Lock' }, diff --git a/tests/utils/help-json.test.ts b/tests/utils/help-json.test.ts index 9f83b4e..a3925b7 100644 --- a/tests/utils/help-json.test.ts +++ b/tests/utils/help-json.test.ts @@ -109,4 +109,53 @@ describe('resolveTargetCommand', () => { const result = resolveTargetCommand(root, ['d', '--help']); expect(result.name()).toBe('devices'); }); + + it('skips a value-consuming flag and its value (--config )', () => { + const root = new Command('switchbot'); + root.option('--json', 'json mode'); + root.option('--config ', 'config path'); + const cache = root.command('cache'); + cache.command('show'); + + expect(resolveTargetCommand(root, ['--json', '--config', '/tmp/cfg.json', 'cache']).name()).toBe('cache'); + }); + + it('skips --profile value before subcommand', () => { + const root = new Command('switchbot'); + root.option('--json', 'json mode'); + root.option('--profile ', 'profile'); + root.command('history'); + + expect(resolveTargetCommand(root, ['--json', '--profile', 'default', 'history']).name()).toBe('history'); + }); + + it('handles inline flag value (--config=path) without consuming next token', () => { + const root = new Command('switchbot'); + root.option('--config ', 'config path'); + root.command('cache'); + + expect(resolveTargetCommand(root, ['--config=/tmp/cfg.json', 'cache']).name()).toBe('cache'); + }); + + it('skips multiple sequential value-consuming flags before subcommand', () => { + const root = new Command('switchbot'); + root.option('--config ', 'config path'); + root.option('--profile ', 'profile'); + root.command('cache'); + + expect( + resolveTargetCommand(root, ['--config', '/tmp/c.json', '--profile', 'home', 'cache']).name() + ).toBe('cache'); + }); + + it('skips root-level value-consuming flags that appear after a subcommand token', () => { + const root = new Command('switchbot'); + root.option('--config ', 'config path'); + const devices = root.command('devices'); + devices.command('list'); + + expect( + resolveTargetCommand(root, ['devices', '--config', '/tmp/c.json', 'list']).name() + ).toBe('list'); + }); }); diff --git a/tests/utils/output.test.ts b/tests/utils/output.test.ts index 05b6a73..f9883e9 100644 --- a/tests/utils/output.test.ts +++ b/tests/utils/output.test.ts @@ -43,7 +43,7 @@ describe('printJson', () => { const out = logSpy.mock.calls[0][0]; expect(out).toBe(JSON.stringify({ schemaVersion: SCHEMA_VERSION, data: { a: 1, b: [2, 3] } }, null, 2)); expect(out).toContain('\n '); - expect(JSON.parse(out)).toEqual({ schemaVersion: '1.1', data: { a: 1, b: [2, 3] } }); + expect(JSON.parse(out)).toEqual({ schemaVersion: '1.2', data: { a: 1, b: [2, 3] } }); }); it('wraps null and primitive payloads inside data', () => { @@ -53,9 +53,9 @@ describe('printJson', () => { printJson('hi'); const parsed = logSpy.mock.calls.map((c) => JSON.parse(String(c[0]))); expect(parsed).toEqual([ - { schemaVersion: '1.1', data: null }, - { schemaVersion: '1.1', data: 42 }, - { schemaVersion: '1.1', data: 'hi' }, + { schemaVersion: '1.2', data: null }, + { schemaVersion: '1.2', data: 42 }, + { schemaVersion: '1.2', data: 'hi' }, ]); }); }); @@ -255,8 +255,7 @@ describe('handleError', () => { expect(() => handleError(new ApiError('bad device', 190))).toThrow('__exit'); const raw = logSpy.mock.calls[0][0]; const parsed = JSON.parse(raw); - expect(parsed.schemaVersion).toBe('1.1'); - expect(parsed.error.code).toBe(190); + expect(parsed.schemaVersion).toBe('1.2'); expect(parsed.error.message).toBe('bad device'); expect(parsed.error.hint).toMatch(/generic internal error/); });