diff --git a/CHANGELOG.md b/CHANGELOG.md index 1148f88..71abc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.2] - 2026-04-21 + +Patch release — CI size-budget fix. + +### Fixed + +- **`schema export --compact`** — dropped the `resources` block from compact output. In v2.7.0 the resources catalog (scenes / webhooks / keys, ~12 KB) was added to the schema payload unconditionally, which pushed `schema export --compact --used` past the 15 KB agent-prompt budget enforced by CI. The `resources` block is still emitted under the full (non-`--compact`) output, and is always available via `capabilities --json`, which is the canonical source for CLI resource metadata. No behaviour change for `capabilities --json` consumers. + +## [2.7.1] - 2026-04-21 + +AI-discoverability patch. Top-level `--help` / `--help --json` and every +subcommand description now lead with the SwitchBot product category +(smart home: lights, locks, curtains, sensors, plugs, IR appliances) so +AI agents reading help text can identify scope without parsing the +catalog. Identity is consolidated into a single module to prevent drift. + +### Changed + +- **Top-level `switchbot --help`** — program description rewritten to "SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time events, and integrate AI agents via MCP." (previously the terse "Command-line tool for SwitchBot API v1.1"). Both human and AI scanners now learn the product category on the first line. +- **`switchbot --help --json` (root)** — now carries top-level `product`, `domain`, `vendor`, `apiVersion`, `apiDocs`, and `productCategories[]` fields for programmatic discovery. Subcommand `--help --json` output is unchanged (identity is root-only to keep per-command payloads tight). +- **Subcommand descriptions** — `catalog`, `schema`, `history`, `plan`, `doctor`, `capabilities` now explicitly mention "SwitchBot" so each command self-describes in `--help` (the other 10 top-level commands already mentioned it). +- **README intro** — rewritten to lead with the product category ("SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances …") instead of the API version. + +### Refactored + +- **Shared IDENTITY module** — extracted the product-identity constant to `src/commands/identity.ts`; `capabilities.ts`, `agent-bootstrap.ts`, and `utils/help-json.ts` now import from a single source of truth to prevent field drift. The canonical IDENTITY adds `productCategories: string[]` (8 category keywords AI agents can scan) and clarifies `constraints.transport = "Cloud API v1.1 (HTTPS)"` — the CLI does **not** drive BLE radios directly; BLE-only devices are reached through a SwitchBot Hub, which the Cloud API handles transparently. `agent-bootstrap --json` gains additive identity fields (`apiDocs`, `deviceCategories`, `productCategories`, `agentGuide`) via the shared module; no fields removed. + +## [2.7.0] - 2026-04-21 + +AI-first maturity release. Broader field-alias coverage, richer capability +metadata, and agent-discoverable resource surfaces (scenes, webhooks, keys). + +### Added + +- **Field aliases** — registry expanded from ~10 to ~51 canonical keys (~98% coverage of catalog `statusFields` + webhook payload fields), dispatched through `devices status`, `devices watch`, and `--fields` parsers. Phase 4 sweep adds ultra-niche sensor/webhook aliases: `waterLeakDetect`, `pressure`, `moveCount`, `errorCode`, `buttonName`, `pressedAt`, `deviceMac`, `detectionState`. +- **safetyTier enum (5 tiers)** — catalog commands now carry `safetyTier: 'read' | 'mutation' | 'ir-fire-forget' | 'destructive' | 'maintenance'`; replaces the legacy `destructive: boolean` flag. +- **`DeviceCatalogEntry.statusQueries`** — read-tier catalog entries exposing queryable status fields; derived from existing `statusFields` plus a curated `STATUS_FIELD_DESCRIPTIONS` map. Powers `safetyTier: 'read'` and lights up `capabilities.catalog.readOnlyQueryCount`. +- **`capabilities.resources`** — new top-level `resources` block in `capabilities --json` and `schema export`, exposing scenes (list/execute/describe), webhooks (4 endpoints + 15 event specs + constraints), and keypad keys (4 types: permanent/timeLimit/disposable/urgent). Each endpoint/event declares its safety tier so agents can plan without trial-and-error. +- **Multi-format output** — `--format=yaml` and `--format=tsv` for all non-streaming commands (devices list, scenes list, catalog, etc.); `id` / `markdown` formats preserved. `--json` remains the alias for `--format=json`. +- **doctor upgrades** — new `--section`, `--list`, `--fix`, `--yes`, `--probe` flags; new checks `catalog-schema`, `audit`, `mcp` (dry-run — instantiates MCP server and counts registered tools), plus live MQTT probe (guarded by `--probe`, 5 s timeout). +- **Streaming JSON contract** — every streaming command (watch / events tail / events mqtt-tail) now emits a `{ schemaVersion, stream: true, eventKind, cadence }` header as its first NDJSON line; documented in `docs/json-contract.md`. +- **Events envelope** — unified `{ schemaVersion, t, source, deviceId, topic, type, payload }` shape across `events tail` and `events mqtt-tail`. +- **MCP tool schema completeness** — every tool input schema now carries `.describe()` annotations; new test suite enforces this. +- **Help-JSON contract test** — table-driven coverage for all 16 top-level commands. +- **batch `--emit-plan`** — new canonical flag alias for the deprecated `--plan`. + +### Changed + +- **Error envelope** — all error paths route through `exitWithError()` / `handleError()`; `--json` failure output always carries `schemaVersion` + structured `error` object. +- **Quota accounting** — requests are recorded on attempt (request interceptor) instead of on success, so timeouts / 4xx / 5xx count against daily quota. +- **`--json` vs `--format=json`** — both paths go through the same formatter; `--json` is now documented as the alias. + +### Deprecated + +- `destructive: boolean` on catalog entries — derived from `safetyTier === 'destructive'`. Removed in v3.0. +- `DeviceCatalogEntry.statusFields` — superseded by `statusQueries`. Removed in v3.0. +- `batch --plan` — renamed to `--emit-plan`. Old flag still works but prints a deprecation warning to stderr. Removed in v3.0. +- Events legacy fields `body` / `remote` on `events tail` — superseded by the unified envelope. Removed in v3.0. + +### Reserved + +- `safetyTier: 'maintenance'` — enum value accepted by the type system but no catalog entry uses it today. Reserved for future SwitchBot API endpoints (factoryReset, firmwareUpdate, deepCalibrate). + +### Fixed + +- Quota counter no longer under-counts requests that fail at the transport or server layer. + ## [2.6.4] - 2026-04-21 ### Added diff --git a/README.md b/README.md index 47f8c45..2873efb 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ [![node](https://img.shields.io/node/v/@switchbot/openapi-cli.svg)](https://nodejs.org) [![CI](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenWonderLabs/switchbot-openapi-cli/actions/workflows/ci.yml) -Command-line interface for the [SwitchBot API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). -List devices, query live status, send control commands, run scenes, receive real-time events, and connect AI agents via the built-in MCP server — all from your terminal or shell scripts. +**SwitchBot** smart home CLI — control lights, locks, curtains, sensors, plugs, and IR appliances (TV/AC/fan) via the [SwitchBot Cloud API v1.1](https://github.com/OpenWonderLabs/SwitchBotAPI). +Run scenes, stream real-time events over MQTT, and plug AI agents into your home via the built-in MCP server — all from your terminal or shell scripts. - **npm package:** [`@switchbot/openapi-cli`](https://www.npmjs.com/package/@switchbot/openapi-cli) - **Source code:** [github.com/OpenWonderLabs/switchbot-openapi-cli](https://github.com/OpenWonderLabs/switchbot-openapi-cli) diff --git a/docs/json-contract.md b/docs/json-contract.md new file mode 100644 index 0000000..aa36adf --- /dev/null +++ b/docs/json-contract.md @@ -0,0 +1,189 @@ +# JSON Output Contract + +`switchbot-cli` emits machine-readable output on **stdout** whenever you pass +`--json` (or the `--format=json` alias once P13 lands). Stderr is reserved for +human-facing progress / warnings and is never part of the contract. + +There are two output shapes. You pick the right parser by the shape of the +**first line** emitted on stdout. + +--- + +## 1. Single-object / array commands (non-streaming) + +Most commands — `devices list`, `devices status`, `devices describe`, +`capabilities`, `schema export`, `doctor`, `catalog show`, `history list`, +`scenes list`, `webhook query`, etc. — emit **exactly one** JSON envelope on +stdout. + +### Success envelope + +```json +{ + "schemaVersion": "1.1", + "data": +} +``` + +- `schemaVersion` tracks the envelope shape, not the inner payload shape. + The envelope version only bumps when `data` moves or is renamed; inner + payload changes get called out in `CHANGELOG.md` on a per-command basis. +- `data` is always present on success (never `null`). + +### Error envelope + +```json +{ + "schemaVersion": "1.1", + "error": { + "code": 2, + "kind": "usage" | "guard" | "api" | "runtime", + "message": "human-readable description", + "hint": "optional remediation string", + "context": { "optional, command-specific": true } + } +} +``` + +- Both success and error envelopes are written to **stdout** so a single + `cli --json ... | jq` pipe can decode either shape (SYS-1 contract). +- `code` is the process exit code. `2` = usage / guard, `1` = runtime / api. +- Additional fields may appear on specific error classes + (`retryable`, `retryAfterMs`, `transient`, `subKind`, `errorClass`). + +--- + +## 2. Streaming / NDJSON commands + +Three commands emit one JSON document per line (NDJSON) instead of a single +envelope: + +| Command | `eventKind` | `cadence` | +|---------------------|-------------|-----------| +| `devices watch` | `tick` | `poll` | +| `events tail` | `event` | `push` | +| `events mqtt-tail` | `event` | `push` | + +### Stream header (always the first line under `--json`) + +```json +{ "schemaVersion": "1", "stream": true, "eventKind": "tick" | "event", "cadence": "poll" | "push" } +``` + +- **Must always be the first line** on stdout under `--json`. Consumers + should read one line, parse, and key on `{ "stream": true }` to confirm + they are reading from a streaming command. +- `eventKind` picks the downstream parser. `tick` → `devices watch` shape + with `{ t, tick, deviceId, changed, ... }`. `event` → unified event + envelope (see below). +- `cadence`: + - `poll` — the CLI drives timing. One line per `--interval`. + - `push` — broker/webhook drives timing. Quiet gaps are normal. + +### Event envelope (subsequent lines on `events tail` / `events mqtt-tail`) + +```json +{ + "schemaVersion": "1", + "source": "webhook" | "mqtt", + "kind": "event" | "control", + "t": "2026-04-21T14:23:45.012Z", + "eventId": "uuid-v4-or-null", + "deviceId": "BOT1" | null, + "topic": "/webhook" | "$aws/things/.../shadow/update/accepted", + "payload": { /* source-specific */ }, + "matchedKeys": ["deviceId", "type"] +} +``` + +- `source` and `kind` together tell a consumer how to treat the record. + Control events (`kind: "control"`) carry a `controlKind` like + `"connect"`, `"reconnect"`, `"disconnect"`, `"heartbeat"`. +- `matchedKeys` is only populated on webhook events when `--filter` was + supplied — it lists which filter clauses hit. +- Legacy fields (`body`, `remote`, `path`, `matched`, `type`, `at`) are + still emitted alongside the unified fields for one minor window. They + are **deprecated** and will be removed in the next major release; new + consumers should read only the unified fields above. + +### Tick envelope (subsequent lines on `devices watch`) + +```json +{ + "schemaVersion": "1.1", + "data": { + "t": "2026-04-21T14:23:45.012Z", + "tick": 1, + "deviceId": "BOT1", + "type": "Bot", + "changed": { "power": { "from": null, "to": "on" } } + } +} +``` + +Watch records reuse the single-object envelope (`{ schemaVersion, data }`) +— only the header uses the lean streaming shape. That keeps the existing +watch consumers working: they only need to add a filter that skips the +first header line. + +### Errors from a streaming command + +If a streaming command hits a fatal error mid-stream, it emits the +**error envelope** (section 1) on stdout and exits non-zero. Consumers +should be prepared to see either `{ stream: true }` or `{ error: ... }` +on any line. + +--- + +## 3. Consumer patterns + +**Route by shape** on line 1: + +```bash +# generic: peek at line 1, pick parser +first=$(head -n 1) +if echo "$first" | jq -e '.stream == true' >/dev/null; then + # streaming — subsequent lines are event envelopes + while IFS= read -r line; do + echo "$line" | jq 'select(.kind == "event")' + done +else + # single-object / array — $first already has the whole payload + echo "$first" | jq '.data' +fi +``` + +**Skip the stream header** if you only want events: + +```bash +switchbot events mqtt-tail --json | jq -c 'select(.stream != true)' +``` + +**Detect the error envelope** from any command: + +```bash +switchbot devices status BOT1 --json | jq -e '.error' && exit 1 +``` + +--- + +## 4. Versioning + +- The non-streaming envelope is versioned as `schemaVersion: "1.1"`. +- The streaming header and event envelope are versioned as + `schemaVersion: "1"`. +- The two axes are deliberately separate: adding a field inside `data` + does **not** bump the envelope, but renaming / removing `data` would. +- Breaking changes land on a major release. Additive fields land on a + minor release and are listed under `### Added` in `CHANGELOG.md`. + +--- + +## 5. What this contract does NOT cover + +- Human-readable (`--format=table` or default) output — may change at any + time. +- Stderr output — progress strings, deprecation warnings, TTY hints. Do + not parse stderr. +- In-file history records under `~/.switchbot/device-history/` — see + `docs/schema-versioning.md`. diff --git a/package-lock.json b/package-lock.json index ffe1eee..c4cef26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.6.1", + "version": "2.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.6.1", + "version": "2.7.2", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 0f8275c..7c2e7c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.6.4", + "version": "2.7.2", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot", diff --git a/src/api/client.ts b/src/api/client.ts index daec3fb..3395276 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -103,6 +103,16 @@ export function createClient(): AxiosInstance { throw new DryRunSignal(method, url); } + // P8: record the quota attempt BEFORE the request is dispatched so + // failures (timeouts / DNS errors / 5xx / aborted) also count. Only + // pre-flight refusals (daily-cap, --dry-run) above skip recording + // since they never touch the network. Retries re-enter this + // interceptor and record again, which matches the SwitchBot API + // billing model (every dispatched HTTP request consumes quota). + if (quotaEnabled) { + recordRequest(method, url); + } + return config; }); @@ -112,11 +122,6 @@ export function createClient(): AxiosInstance { if (verbose) { process.stderr.write(chalk.grey(`[verbose] ${response.status} ${response.statusText}\n`)); } - if (quotaEnabled && response.config) { - const method = (response.config.method ?? 'get').toUpperCase(); - const url = `${response.config.baseURL ?? ''}${response.config.url ?? ''}`; - recordRequest(method, url); - } const data = response.data as { statusCode?: number; message?: string }; if (data.statusCode !== undefined && data.statusCode !== 100) { const msg = @@ -210,13 +215,10 @@ export function createClient(): AxiosInstance { } } - // Record exhausted/non-retryable HTTP responses too — they count - // against the daily quota. - if (quotaEnabled && error.response && config) { - const method = (config.method ?? 'get').toUpperCase(); - const url = `${config.baseURL ?? ''}${config.url ?? ''}`; - recordRequest(method, url); - } + // P8: quota already recorded in the request interceptor before + // dispatch — no extra bookkeeping needed here on the error path. + // Timeouts, DNS failures, 5xx, and exhausted retries all counted + // when the attempt was first made. if (status === 401) { throw new ApiError( diff --git a/src/commands/agent-bootstrap.ts b/src/commands/agent-bootstrap.ts index a7d65d0..dcc31d3 100644 --- a/src/commands/agent-bootstrap.ts +++ b/src/commands/agent-bootstrap.ts @@ -1,22 +1,28 @@ import { Command } from 'commander'; import { printJson } from '../utils/output.js'; import { loadCache } from '../devices/cache.js'; -import { getEffectiveCatalog } from '../devices/catalog.js'; +import { + getEffectiveCatalog, + deriveSafetyTier, + CATALOG_SCHEMA_VERSION, +} from '../devices/catalog.js'; import { readProfileMeta } from '../config.js'; import { todayUsage, DAILY_QUOTA } from '../utils/quota.js'; import { ALL_STRATEGIES } from '../utils/name-resolver.js'; +import { IDENTITY } from './identity.js'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { version: pkgVersion } = require('../../package.json') as { version: string }; -const IDENTITY = { - product: 'SwitchBot', - domain: 'IoT smart home device control', - vendor: 'Wonderlabs, Inc.', - apiVersion: 'v1.1', - authMethod: 'HMAC-SHA256 token+secret', -}; +/** + * Schema version of the agent-bootstrap payload. Must stay in lockstep + * with the catalog schema — bootstrap consumers (AI agents) reason about + * catalog-derived fields (safetyTier, destructive flag), so a drift + * between the two would silently break their assumptions. `doctor` + * fails the `catalog-schema` check when these differ. + */ +export const AGENT_BOOTSTRAP_SCHEMA_VERSION = CATALOG_SCHEMA_VERSION; const SAFETY_TIERS = { read: 'No state mutation; safe to call freely.', @@ -107,18 +113,22 @@ Examples: category: e.category, role: e.role ?? null, readOnly: e.readOnly ?? false, - commands: e.commands.map((c) => ({ - command: c.command, - parameter: c.parameter, - destructive: Boolean(c.destructive), - idempotent: Boolean(c.idempotent), - })), + commands: e.commands.map((c) => { + const tier = deriveSafetyTier(c, e); + return { + command: c.command, + parameter: c.parameter, + safetyTier: tier, + destructive: tier === 'destructive', + idempotent: Boolean(c.idempotent), + }; + }), statusFields: e.statusFields ?? [], }; }); const payload: Record = { - schemaVersion: '1.0', + schemaVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION, generatedAt: new Date().toISOString(), cliVersion: pkgVersion, identity: IDENTITY, diff --git a/src/commands/batch.ts b/src/commands/batch.ts index fa69fc1..446b592 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import type { AxiosInstance } from 'axios'; import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js'; -import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, exitWithError, type ErrorPayload } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, exitWithError, type ErrorPayload } from '../utils/output.js'; import { fetchDeviceList, executeCommand, @@ -153,7 +153,8 @@ export function registerBatchCommand(devices: Command): void { .option('--concurrency ', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5') .option('--max-concurrent ', 'Alias for --concurrency; takes priority when set', intArg('--max-concurrent', { min: 1 })) .option('--stagger ', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0') - .option('--plan', 'With --dry-run: emit a plan JSON document instead of executing anything') + .option('--plan', '[DEPRECATED, use --emit-plan] With --dry-run: emit a plan JSON document instead of executing anything') + .option('--emit-plan', 'With --dry-run: emit a plan JSON document instead of executing anything') .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)') .option('--type ', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command') .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")') @@ -184,8 +185,9 @@ Concurrency & pacing: --stagger Fixed delay between task starts; default 0 uses random 20-60ms jitter. Planning: - --dry-run --plan Print the plan JSON without executing anything. Useful + --dry-run --emit-plan Print the plan JSON without executing anything. Useful for agents that want to show the user what will run. + (--plan is the deprecated alias, removed in v3.0.) Safety: Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff, @@ -210,6 +212,7 @@ Examples: maxConcurrent?: string; stagger: string; plan?: boolean; + emitPlan?: boolean; yes?: boolean; type: string; stdin?: boolean; @@ -222,6 +225,17 @@ Examples: // Trailing "-" sentinel selects stdin mode. const extra = commandObj.args ?? []; const readStdin = Boolean(options.stdin) || extra.includes('-'); + // P12: --plan is deprecated in favor of --emit-plan. Reject both + // together (conflicting) and warn when only the old flag is used. + if (options.plan && options.emitPlan) { + handleError(new UsageError('Use --emit-plan; --plan is deprecated and cannot be combined with --emit-plan.')); + return; + } + if (options.plan && !options.emitPlan) { + // Warning goes to stderr so it cannot corrupt --json output on stdout. + console.error('[WARN] --plan is deprecated; use --emit-plan. Will be removed in v3.0.'); + } + const emitPlan = Boolean(options.emitPlan || options.plan); // Accept --idempotency-key as alias; reject when both forms are supplied. if (options.idempotencyKey !== undefined && options.idempotencyKeyPrefix !== undefined) { handleError(new UsageError('Use either --idempotency-key or --idempotency-key-prefix, not both.')); @@ -296,22 +310,14 @@ Examples: } if (blockedForDestructive.length > 0 && !options.yes) { - if (isJsonMode()) { - const deviceIds = blockedForDestructive.map((b) => b.deviceId); - emitJsonError({ - code: 2, - kind: 'guard', - message: `Destructive command "${cmd}" requires --yes to run on ${blockedForDestructive.length} device(s).`, - hint: 'Re-issue the call with --yes to proceed.', - context: { command: cmd, deviceIds }, - }); - } else { - console.error( - `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes:` - ); - for (const b of blockedForDestructive) console.error(` ${b.deviceId}`); - } - process.exit(2); + const deviceIds = blockedForDestructive.map((b) => b.deviceId); + exitWithError({ + code: 2, + kind: 'guard', + message: `Refusing to run destructive command "${cmd}" on ${blockedForDestructive.length} device(s) without --yes: ${deviceIds.join(', ')}`, + hint: 'Re-issue the call with --yes to proceed.', + context: { command: cmd, deviceIds }, + }); } // parameter may be a JSON object string; mirror the single-command action. @@ -329,8 +335,8 @@ Examples: const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0); const dryRun = isDryRun(); - // --dry-run --plan: emit a plan document and return without executing. - if (dryRun && options.plan) { + // --dry-run --emit-plan (or legacy --plan): emit a plan document and return without executing. + if (dryRun && emitPlan) { const steps = resolved.ids.map((id) => ({ deviceId: id, command: cmd, diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index cc5b0d3..728ee28 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -1,8 +1,36 @@ import { Command } from 'commander'; -import { getEffectiveCatalog } from '../devices/catalog.js'; +import { + getEffectiveCatalog, + deriveSafetyTier, + deriveStatusQueries, + type DeviceCatalogEntry, + type SafetyTier, +} from '../devices/catalog.js'; +import { RESOURCE_CATALOG } from '../devices/resources.js'; import { loadCache } from '../devices/cache.js'; import { printJson } from '../utils/output.js'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; +import { IDENTITY } from './identity.js'; + +/** Collect the distinct catalog safety tiers actually used across the given entries. Sorted. */ +function collectSafetyTiersInUse(entries: DeviceCatalogEntry[]): SafetyTier[] { + const seen = new Set(); + for (const e of entries) { + for (const c of e.commands) { + seen.add(deriveSafetyTier(c, e)); + } + // P11: statusQueries contribute the 'read' tier. + if (deriveStatusQueries(e).length > 0) { + seen.add('read'); + } + } + return [...seen].sort(); +} + +/** P11: total number of read-only queries exposed across the catalog. */ +function countStatusQueries(entries: DeviceCatalogEntry[]): number { + return entries.reduce((n, e) => n + deriveStatusQueries(e).length, 0); +} export type AgentSafetyTier = 'read' | 'action' | 'destructive'; export type Verifiability = 'local' | 'deviceConfirmed' | 'deviceDependent' | 'none'; @@ -88,24 +116,6 @@ function metaFor(command: string): CommandMeta | null { return COMMAND_META[command] ?? null; } -const IDENTITY = { - product: 'SwitchBot', - domain: 'IoT smart home device control', - vendor: 'Wonderlabs, Inc.', - apiVersion: 'v1.1', - apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI', - deviceCategories: { - physical: 'Wi-Fi/BLE devices controllable via Cloud API (Hub required for BLE-only)', - ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, etc.)', - }, - constraints: { - quotaPerDay: 10000, - bleRequiresHub: true, - authMethod: 'HMAC-SHA256 token+secret', - }, - agentGuide: 'docs/agent-guide.md', -}; - const MCP_TOOLS = [ 'list_devices', 'get_device_status', @@ -179,7 +189,7 @@ export function registerCapabilitiesCommand(program: Command): void { const SURFACES = ['cli', 'mcp', 'plan', 'mqtt', 'all'] as const; program .command('capabilities') - .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)') + .description('Print a machine-readable manifest of SwitchBot CLI capabilities (for AI agent bootstrap)') .option('--minimal', 'Omit per-subcommand flag details to reduce output size (alias of --compact)') .option('--compact', 'Emit a compact summary: identity + leaf command list with safety metadata only') .option('--used', 'Restrict the catalog summary to device types present in the local cache. Mirrors `schema export --used`.') @@ -286,11 +296,15 @@ export function registerCapabilitiesCommand(program: Command): void { typeCount: catalog.length, roles, destructiveCommandCount: catalog.reduce( - (n, e) => n + e.commands.filter((c) => c.destructive).length, + (n, e) => + n + e.commands.filter((c) => deriveSafetyTier(c, e) === 'destructive').length, 0, ), + safetyTiersInUse: collectSafetyTiersInUse(catalog), readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, + readOnlyQueryCount: countStatusQueries(catalog), }, + resources: RESOURCE_CATALOG, }; if (!compact) payload.generatedAt = new Date().toISOString(); @@ -313,10 +327,13 @@ export function registerCapabilitiesCommand(program: Command): void { typeCount: filteredCatalog.length, roles: [...new Set(filteredCatalog.map((e) => e.role ?? 'other'))].sort(), destructiveCommandCount: filteredCatalog.reduce( - (n, e) => n + e.commands.filter((c) => c.destructive).length, + (n, e) => + n + e.commands.filter((c) => deriveSafetyTier(c, e) === 'destructive').length, 0, ), + safetyTiersInUse: collectSafetyTiersInUse(filteredCatalog), readOnlyTypeCount: filteredCatalog.filter((e) => e.readOnly).length, + readOnlyQueryCount: countStatusQueries(filteredCatalog), }; payload.usedFilter = { applied: true, typesInCache: [...seen].sort() }; } diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index df7f1cf..9e72b40 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -9,6 +9,7 @@ import { getEffectiveCatalog, loadCatalogOverlay, resetCatalogOverlayCache, + deriveSafetyTier, type DeviceCatalogEntry, } from '../devices/catalog.js'; @@ -16,7 +17,7 @@ export function registerCatalogCommand(program: Command): void { const SOURCES = ['built-in', 'overlay', 'effective'] as const; const catalog = program .command('catalog') - .description('Inspect the built-in device catalog and any local overlay') + .description('Inspect the SwitchBot device catalog (supported device types + any local overlay)') .addHelpText('after', ` This CLI ships with a static catalog of known SwitchBot device types and their commands (see 'switchbot devices types'). You can extend or override @@ -343,9 +344,10 @@ function renderEntry(entry: DeviceCatalogEntry): void { } else { console.log('\nCommands:'); const rows = entry.commands.map((c) => { + const tier = deriveSafetyTier(c, entry); const flags: string[] = []; if (c.commandType === 'customize') flags.push('customize'); - if (c.destructive) flags.push('!destructive'); + if (tier === 'destructive') flags.push('!destructive'); const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command; return [label, c.parameter, c.description]; }); diff --git a/src/commands/config.ts b/src/commands/config.ts index fec3006..5376db9 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -5,7 +5,7 @@ import { execFileSync } from 'node:child_process'; import { stringArg } from '../utils/arg-parsers.js'; import { intArg } from '../utils/arg-parsers.js'; import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js'; -import { isJsonMode, printJson, emitJsonError } from '../utils/output.js'; +import { isJsonMode, printJson, exitWithError } from '../utils/output.js'; import chalk from 'chalk'; function parseEnvFile(file: string): { token?: string; secret?: string } { @@ -162,13 +162,11 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ for the secret reference.', + }); } try { token = readFromOp(options.fromOp); secret = readFromOp(options.opSecret); } catch (err) { - const msg = `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`; - if (isJsonMode()) { - emitJsonError({ code: 1, kind: 'runtime', message: msg, hint: 'Ensure the "op" CLI is installed and authenticated (op signin).' }); - } else { - console.error(msg); - console.error('Ensure the "op" CLI is installed and authenticated (op signin).'); - } - process.exit(1); + exitWithError({ + code: 1, + kind: 'runtime', + message: `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`, + hint: 'Ensure the "op" CLI is installed and authenticated (op signin).', + }); } } // No credentials yet and stdin is a TTY → interactive prompt (safest path). if ((!token || !secret) && !options.fromEnvFile && !options.fromOp && process.stdin.isTTY) { if (isJsonMode()) { - const msg = 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.'; - emitJsonError({ code: 2, kind: 'usage', message: msg }); - process.exit(2); + exitWithError({ + code: 2, + kind: 'usage', + message: 'Interactive mode cannot run under --json. Provide token/secret via --from-env-file, --from-op, or positional args.', + }); } try { if (!token) token = (await promptSecret('Token: ')).trim(); @@ -217,13 +213,11 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ = { 'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' }, @@ -103,7 +109,7 @@ Examples: `) .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"') - .option('--filter ', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter')) + .option('--filter ', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category, familyName/family, hubDeviceId/hub, roomID/roomid, enableCloudService/cloud, alias.', stringArg('--filter')) .action(async (options: { wide?: boolean; showHidden?: boolean; filter?: string }) => { try { const body = await fetchDeviceList(); @@ -115,8 +121,11 @@ Examples: // Parse --filter into a list of clauses. Shared grammar across // `devices list`, `devices batch`, and `events tail` / `mqtt-tail`. - const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'] as const; - const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', 'roomName', 'category'] as const; + const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType', + 'family', 'hub', 'roomID', 'cloud', 'alias'] as const; + const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', + 'roomName', 'category', 'familyName', 'hubDeviceId', 'roomID', + 'enableCloudService', 'alias'] as const; const LIST_FILTER_TO_RUNTIME: Record = { deviceId: 'deviceId', deviceName: 'name', @@ -124,6 +133,11 @@ Examples: controlType: 'controlType', roomName: 'room', category: 'category', + familyName: 'family', + hubDeviceId: 'hub', + roomID: 'roomID', + enableCloudService: 'cloud', + alias: 'alias', }; let listClauses: FilterClause[] | null = null; if (options.filter) { @@ -141,7 +155,11 @@ Examples: } } - const matchesFilter = (entry: { deviceId: string; type: string; name: string; category: 'physical' | 'ir'; room: string; controlType: string }) => { + const matchesFilter = (entry: { + deviceId: string; type: string; name: string; category: 'physical' | 'ir'; + room: string; controlType: string; family: string; hub: string; + roomID: string; cloud: string; alias: string; + }) => { if (!listClauses || listClauses.length === 0) return true; for (const c of listClauses) { const fieldVal = (entry as Record)[c.key] ?? ''; @@ -153,11 +171,11 @@ Examples: if (fmt === 'json' && process.argv.includes('--json')) { if (listClauses) { const filteredDeviceList = deviceList.filter((d) => - matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }) + matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }) ); const filteredIrList = infraredRemoteList.filter((d) => { const inherited = hubLocation.get(d.hubDeviceId); - return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }); + return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }); }); printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList }); } else { @@ -174,7 +192,7 @@ Examples: for (const d of deviceList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; - if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' })) continue; + if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -193,7 +211,7 @@ Examples: for (const d of infraredRemoteList) { if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden) continue; const inherited = hubLocation.get(d.hubDeviceId); - if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' })) continue; + if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' })) continue; rows.push([ d.deviceId, d.deviceName, @@ -295,16 +313,20 @@ Examples: console.log(JSON.stringify(entry)); } } else { - const fields = resolveFields(); + const rawFields = resolveFields(); for (const entry of batch) { const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry as Record; console.log(`\n─── ${String(deviceId)} ───`); if (!ok) { console.error(` error: ${String(error)}`); } else { + const statusMap = status as Record; + const fields = rawFields + ? resolveFieldList(rawFields, Object.keys(statusMap)) + : undefined; const displayStatus: Record = fields - ? Object.fromEntries(fields.map((f) => [f, (status as Record)[f] ?? null])) - : (status as Record); + ? Object.fromEntries(fields.map((f) => [f, statusMap[f] ?? null])) + : statusMap; printKeyValue(displayStatus); console.error(` fetched at ${String(ts)}`); } @@ -332,7 +354,10 @@ Examples: const statusWithTs = { ...(body as Record), _fetchedAt: fetchedAt }; const allHeaders = Object.keys(statusWithTs); const allRows = [Object.values(statusWithTs) as unknown[]]; - const fields = resolveFields(); + const rawFields = resolveFields(); + const fields = rawFields + ? resolveFieldList(rawFields, allHeaders) + : undefined; renderRows(allHeaders, allRows, fmt, fields); return; } @@ -475,27 +500,23 @@ Examples: const validation = validateCommand(deviceId, cmd, parameter, options.type); if (!validation.ok) { const err = validation.error; - if (isJsonMode()) { - const obj: Record = { code: 2, kind: 'usage', message: err.message }; - if (err.hint) obj.hint = err.hint; - obj.context = { validationKind: err.kind }; - emitJsonError(obj); - } else { - console.error(`Error: ${err.message}`); - if (err.hint) console.error(err.hint); - if (err.kind === 'unknown-command') { - const cached = getCachedDevice(deviceId); - if (cached) { - console.error( - `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.` - ); - console.error( - `(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)` - ); - } + let hint = err.hint; + if (err.kind === 'unknown-command') { + const cached = getCachedDevice(deviceId); + if (cached) { + const extra = + `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.\n` + + `(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`; + hint = hint ? `${hint}\n${extra}` : extra; } } - process.exit(2); + exitWithError({ + code: 2, + kind: 'usage', + message: err.message, + hint, + context: { validationKind: err.kind }, + }); } // Case-only mismatch: emit a warning and continue with the canonical name. @@ -535,7 +556,7 @@ Examples: hint: reason ? `Re-run with --yes to confirm. Reason: ${reason}` : 'Re-run with --yes to confirm, or --dry-run to preview without sending.', - context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) }, + context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) }, }); } @@ -682,7 +703,7 @@ Examples: const joinedMatch = findCatalogEntry(joined); if (joinedMatch && !Array.isArray(joinedMatch)) { if (isJsonMode()) { - printJson(joinedMatch); + printJson(normalizeCatalogForJson(joinedMatch)); } else { renderCatalogEntry(joinedMatch); } @@ -701,7 +722,7 @@ Examples: } if (individualMatches.length === typeParts.length) { if (isJsonMode()) { - printJson(individualMatches); + printJson(individualMatches.map(normalizeCatalogForJson)); } else { individualMatches.forEach((entry, i) => { if (i > 0) console.log(''); @@ -871,6 +892,22 @@ Examples: registerDevicesMetaCommand(devices); } +function normalizeCatalogForJson(entry: DeviceCatalogEntry): object { + return { + ...entry, + commands: entry.commands.map((c) => { + const tier = deriveSafetyTier(c, entry); + const reason = getCommandSafetyReason(c); + return { + ...c, + safetyTier: tier, + destructive: tier === 'destructive', + ...(reason ? { safetyReason: reason } : {}), + }; + }), + }; +} + function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log(`Type: ${entry.type}`); console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`); @@ -886,9 +923,10 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log('\nCommands:'); const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0); const rows = entry.commands.map((c) => { + const tier = deriveSafetyTier(c, entry); const flags: string[] = []; if (c.commandType === 'customize') flags.push('customize'); - if (c.destructive) flags.push('!destructive'); + if (tier === 'destructive') flags.push('!destructive'); const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command; const base = [label, c.parameter, c.description]; return hasExamples ? [...base, (c.exampleParams ?? []).join(' | ') || ''] : base; @@ -897,7 +935,9 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { ? ['command', 'parameter', 'description', 'example'] : ['command', 'parameter', 'description']; printTable(tableHeaders, rows); - const hasDestructive = entry.commands.some((c) => c.destructive); + const hasDestructive = entry.commands.some( + (c) => deriveSafetyTier(c, entry) === 'destructive', + ); if (hasDestructive) { console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.'); } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f290693..1c54498 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,10 +2,14 @@ import { Command } from 'commander'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { printJson, isJsonMode } from '../utils/output.js'; +import { printJson, isJsonMode, exitWithError } from '../utils/output.js'; import { getEffectiveCatalog } from '../devices/catalog.js'; import { configFilePath, listProfiles, readProfileMeta } from '../config.js'; -import { describeCache } from '../devices/cache.js'; +import { describeCache, resetListCache } from '../devices/cache.js'; +import { DAILY_QUOTA, todayUsage } from '../utils/quota.js'; +import { AGENT_BOOTSTRAP_SCHEMA_VERSION } from './agent-bootstrap.js'; +import { CATALOG_SCHEMA_VERSION } from '../devices/catalog.js'; +import { createSwitchBotMcpServer, listRegisteredTools } from './mcp.js'; interface Check { name: string; @@ -169,14 +173,155 @@ function checkCache(): Check { function checkQuotaFile(): Check { const p = path.join(os.homedir(), '.switchbot', 'quota.json'); if (!fs.existsSync(p)) { - return { name: 'quota', status: 'ok', detail: 'no quota file yet (will be created on first call)' }; + return { + name: 'quota', + status: 'ok', + detail: { + path: p, + percentUsed: 0, + remaining: DAILY_QUOTA, + message: 'no quota file yet (will be created on first call)', + }, + }; } try { const raw = fs.readFileSync(p, 'utf-8'); JSON.parse(raw); - return { name: 'quota', status: 'ok', detail: p }; } catch { - return { name: 'quota', status: 'warn', detail: `${p} unreadable/malformed — run 'switchbot quota reset'` }; + return { + name: 'quota', + status: 'warn', + detail: { path: p, message: `unreadable/malformed — run 'switchbot quota reset'` }, + }; + } + // P9: surface headroom so agents can decide when to slow down or pause. + // Quota resets at local midnight (the quota counter buckets by local + // date), so project the next reset to the next 00:00:00 local. + const usage = todayUsage(); + const percentUsed = Math.round((usage.total / DAILY_QUOTA) * 100); + const now = new Date(); + const reset = new Date(now); + reset.setHours(24, 0, 0, 0); // next local midnight + const status: 'ok' | 'warn' = percentUsed > 80 ? 'warn' : 'ok'; + const recommendation = percentUsed > 90 + ? 'over 90% used — consider --no-quota for read-only triage or rescheduling work after the reset' + : percentUsed > 80 + ? 'over 80% used — avoid bulk operations until the daily reset' + : 'headroom available'; + return { + name: 'quota', + status, + detail: { + path: p, + percentUsed, + remaining: usage.remaining, + total: usage.total, + dailyCap: DAILY_QUOTA, + projectedResetTime: reset.toISOString(), + recommendation, + }, + }; +} + +function checkCatalogSchema(): Check { + // P9: sentinel against silent drift between the catalog shape and the + // agent-bootstrap payload. Both constants are exported from their + // respective modules; if a future refactor changes one without the + // other, this check fails so consumers (agents) learn before the + // mismatch corrupts their mental model. + const match = CATALOG_SCHEMA_VERSION === AGENT_BOOTSTRAP_SCHEMA_VERSION; + return { + name: 'catalog-schema', + status: match ? 'ok' : 'fail', + detail: { + catalogSchemaVersion: CATALOG_SCHEMA_VERSION, + bootstrapExpectsVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION, + match, + message: match + ? 'catalog and agent-bootstrap schemaVersion aligned' + : 'catalog and agent-bootstrap schemaVersion have drifted — bump in lockstep', + }, + }; +} + +interface AuditRecord { + auditVersion?: number; + t?: string; + kind?: string; + deviceId?: string; + command?: string; + result?: 'ok' | 'error'; + error?: string; +} + +function checkAudit(): Check { + // P9: surface recent command failures so agents / ops can spot problems + // before they page. When --audit-log was never enabled, the file won't + // exist — report that cleanly rather than as an error. + const p = path.join(os.homedir(), '.switchbot', 'audit.log'); + if (!fs.existsSync(p)) { + return { + name: 'audit', + status: 'ok', + detail: { + path: p, + enabled: false, + message: 'audit log not present (enable with --audit-log)', + }, + }; + } + try { + const raw = fs.readFileSync(p, 'utf-8'); + const since = Date.now() - 24 * 60 * 60 * 1000; + const recent: Array<{ t: string; command: string; deviceId?: string; error: string }> = []; + let total = 0; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + let rec: AuditRecord; + try { + rec = JSON.parse(trimmed) as AuditRecord; + } catch { + continue; + } + if (rec.result !== 'error') continue; + total += 1; + const ts = rec.t ? Date.parse(rec.t) : NaN; + if (Number.isFinite(ts) && ts >= since) { + recent.push({ + t: rec.t as string, + command: rec.command ?? '?', + deviceId: rec.deviceId, + error: rec.error ?? 'unknown', + }); + } + } + // Cap the report to the 10 most recent so the doctor payload stays + // bounded even on a log with thousands of errors. + recent.sort((a, b) => (a.t < b.t ? 1 : -1)); + const clipped = recent.slice(0, 10); + const status: 'ok' | 'warn' = recent.length > 0 ? 'warn' : 'ok'; + return { + name: 'audit', + status, + detail: { + path: p, + enabled: true, + totalErrors: total, + errorsLast24h: recent.length, + recent: clipped, + }, + }; + } catch (err) { + return { + name: 'audit', + status: 'warn', + detail: { + path: p, + enabled: true, + message: `could not read audit log: ${err instanceof Error ? err.message : String(err)}`, + }, + }; } } @@ -220,10 +365,185 @@ function checkMqtt(): Check { }; } +async function checkMqttProbe(): Promise { + // P10: live-probe the MQTT broker. Only runs when --probe is passed. + // Does not subscribe — just connects + disconnects to verify the + // credential + TLS handshake works end-to-end. Hard 5s timeout so + // a misbehaving broker never wedges the doctor command. + const { fetchMqttCredential } = await import('../mqtt/credential.js'); + const { SwitchBotMqttClient } = await import('../mqtt/client.js'); + + const token = process.env.SWITCHBOT_TOKEN; + const secret = process.env.SWITCHBOT_SECRET; + let creds: { token: string; secret: string } | null = null; + if (token && secret) { + creds = { token, secret }; + } else { + const file = configFilePath(); + if (fs.existsSync(file)) { + try { + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + if (cfg.token && cfg.secret) { + creds = { token: cfg.token, secret: cfg.secret }; + } + } catch { /* fall through */ } + } + } + if (!creds) { + return { + name: 'mqtt', + status: 'warn', + detail: { probe: 'skipped', reason: 'no credentials configured' }, + }; + } + + const deadline = new Promise((_, reject) => + setTimeout(() => reject(new Error('probe timeout after 5000ms')), 5000), + ); + try { + const cred = await Promise.race([fetchMqttCredential(creds.token, creds.secret), deadline]); + const client = new SwitchBotMqttClient(cred); + await Promise.race([client.connect(), deadline]); + await client.disconnect(); + return { + name: 'mqtt', + status: 'ok', + detail: { probe: 'connected', brokerUrl: cred.brokerUrl, region: cred.region }, + }; + } catch (err) { + return { + name: 'mqtt', + status: 'warn', + detail: { probe: 'failed', reason: err instanceof Error ? err.message : String(err) }, + }; + } +} + +function checkMcp(): Check { + // P10: dry-run instantiation of the MCP server to catch tool-registration + // regressions. No network I/O, no token needed. If createSwitchBotMcpServer + // throws (e.g. duplicate tool name, schema build error) the check fails. + try { + const server = createSwitchBotMcpServer(); + const tools = listRegisteredTools(server); + return { + name: 'mcp', + status: 'ok', + detail: { + serverInstantiated: true, + toolCount: tools.length, + tools, + transportsAvailable: ['stdio', 'http'], + message: `${tools.length} tools registered; no network probe`, + }, + }; + } catch (err) { + return { + name: 'mcp', + status: 'fail', + detail: { + serverInstantiated: false, + error: err instanceof Error ? err.message : String(err), + }, + }; + } +} + +interface CheckDef { + name: string; + description: string; + run: (opts: DoctorRunOpts) => Check | Promise; +} + +interface DoctorRunOpts { + probe: boolean; +} + +const CHECK_REGISTRY: CheckDef[] = [ + { name: 'node', description: 'Node.js version compatibility', run: () => checkNodeVersion() }, + { name: 'credentials', description: 'credentials file present and parseable', run: () => checkCredentials() }, + { name: 'profiles', description: 'profile definitions valid', run: () => checkProfiles() }, + { name: 'catalog', description: 'catalog loads', run: () => checkCatalog() }, + { name: 'catalog-schema', description: 'catalog vs agent-bootstrap version aligned', run: () => checkCatalogSchema() }, + { name: 'cache', description: 'device cache state', run: () => checkCache() }, + { name: 'quota', description: 'API quota headroom', run: () => checkQuotaFile() }, + { name: 'clock', description: 'system clock skew', run: () => checkClockSkew() }, + { + name: 'mqtt', + description: 'MQTT credentials (+ --probe for live broker handshake)', + run: ({ probe }) => (probe ? checkMqttProbe() : checkMqtt()), + }, + { name: 'mcp', description: 'MCP server instantiable + tool count', run: () => checkMcp() }, + { name: 'audit', description: 'recent command errors (last 24h)', run: () => checkAudit() }, +]; + +interface FixResult { + check: string; + action: string; + applied: boolean; + message?: string; +} + +function applyFixes(checks: Check[], writeOk: boolean): FixResult[] { + const results: FixResult[] = []; + for (const c of checks) { + if (c.name === 'cache' && c.status !== 'ok') { + if (writeOk) { + try { + resetListCache(); + results.push({ check: 'cache', action: 'cache-cleared', applied: true }); + } catch (err) { + results.push({ + check: 'cache', + action: 'cache-clear', + applied: false, + message: err instanceof Error ? err.message : String(err), + }); + } + } else { + results.push({ + check: 'cache', + action: 'cache-clear', + applied: false, + message: 'pass --yes to apply', + }); + } + } else if (c.name === 'catalog-schema' && c.status !== 'ok') { + results.push({ + check: 'catalog-schema', + action: 'manual', + applied: false, + message: "drift detected — run 'switchbot capabilities --reload' to refresh overlay", + }); + } else if (c.name === 'credentials' && c.status === 'fail') { + results.push({ + check: 'credentials', + action: 'manual', + applied: false, + message: "run 'switchbot config set-token' to configure credentials", + }); + } + } + return results; +} + +interface DoctorCliOptions { + section?: string; + list?: boolean; + fix?: boolean; + yes?: boolean; + probe?: boolean; +} + export function registerDoctorCommand(program: Command): void { program .command('doctor') - .description('Self-check: credentials, catalog, cache, quota, profiles, Node version') + .description('Self-check the SwitchBot CLI setup: credentials, catalog, cache, quota, MQTT, MCP') + .option('--section ', 'Comma-separated list of checks to run (see --list for names)') + .option('--list', 'Print the registered check names and exit 0 without running any check') + .option('--fix', 'Apply safe, reversible remediations for failing checks (e.g. clear stale cache)') + .option('--yes', 'Required together with --fix to confirm write actions') + .option('--probe', 'Perform live-probe variant of checks that support it (mqtt)') .addHelpText('after', ` Runs a battery of local sanity checks and exits with code 0 only when every check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1. @@ -231,18 +551,54 @@ check is 'ok'. 'warn' → exit 0 (informational); 'fail' → exit 1. Examples: $ switchbot doctor $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")' + $ switchbot doctor --list + $ switchbot doctor --section credentials,mcp --json + $ switchbot doctor --probe --json + $ switchbot doctor --fix --yes --json `) - .action(async () => { - const checks: Check[] = [ - checkNodeVersion(), - await checkCredentials(), - checkProfiles(), - checkCatalog(), - checkCache(), - checkQuotaFile(), - await checkClockSkew(), - checkMqtt(), - ]; + .action(async (opts: DoctorCliOptions) => { + // --list: print the registry and exit 0. + if (opts.list) { + if (isJsonMode()) { + printJson({ + checks: CHECK_REGISTRY.map((c) => ({ name: c.name, description: c.description })), + }); + } else { + console.log('Available checks:'); + for (const c of CHECK_REGISTRY) { + console.log(` ${c.name.padEnd(16)} ${c.description}`); + } + } + return; + } + + // --section: run only the named subset, dedup and validate. + let selected: CheckDef[] = CHECK_REGISTRY; + if (opts.section) { + const raw = opts.section.split(',').map((s) => s.trim()).filter(Boolean); + const names = Array.from(new Set(raw)); + const known = new Set(CHECK_REGISTRY.map((c) => c.name)); + const unknown = names.filter((n) => !known.has(n)); + if (unknown.length > 0) { + exitWithError({ + code: 2, + kind: 'usage', + message: `Unknown check name(s): ${unknown.join(', ')}. Valid: ${CHECK_REGISTRY.map((c) => c.name).join(', ')}`, + }); + return; + } + const order = new Map(CHECK_REGISTRY.map((c, i) => [c.name, i])); + selected = names + .map((n) => CHECK_REGISTRY.find((c) => c.name === n)!) + .sort((a, b) => (order.get(a.name)! - order.get(b.name)!)); + } + + const runOpts: DoctorRunOpts = { probe: Boolean(opts.probe) }; + const checks: Check[] = []; + for (const def of selected) { + checks.push(await def.run(runOpts)); + } + const summary = { ok: checks.filter((c) => c.status === 'ok').length, warn: checks.filter((c) => c.status === 'warn').length, @@ -251,20 +607,27 @@ Examples: const overallFail = summary.fail > 0; const overall: 'ok' | 'warn' | 'fail' = overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok'; + let fixes: FixResult[] | undefined; + if (opts.fix) { + fixes = applyFixes(checks, Boolean(opts.yes)); + } + if (isJsonMode()) { // Stable contract (locked as doctor.schemaVersion=1): // { ok: boolean, overall: 'ok'|'warn'|'fail', generatedAt, schemaVersion, // summary: { ok, warn, fail }, checks: [{ name, status, detail }] } // `ok` is an alias of (overall === 'ok') — agents prefer the boolean, // humans prefer the string; both are provided. - printJson({ + const payload: Record = { ok: overall === 'ok', overall, generatedAt: new Date().toISOString(), schemaVersion: DOCTOR_SCHEMA_VERSION, summary, checks, - }); + }; + if (fixes !== undefined) payload.fixes = fixes; + printJson(payload); } else { for (const c of checks) { const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗'; @@ -273,6 +636,14 @@ Examples: } console.log(''); console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`); + if (fixes && fixes.length > 0) { + console.log(''); + console.log('Fixes:'); + for (const f of fixes) { + const marker = f.applied ? '✓' : '-'; + console.log(` ${marker} ${f.check}: ${f.action}${f.message ? ' — ' + f.message : ''}`); + } + } } if (overallFail) process.exit(1); }); diff --git a/src/commands/events.ts b/src/commands/events.ts index 0e18c28..f209bbe 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import http from 'node:http'; import crypto from 'node:crypto'; -import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js'; import { intArg, stringArg, durationArg } from '../utils/arg-parsers.js'; import { parseDurationToMs } from '../utils/flags.js'; import { parseFilterExpr, matchClause, FilterSyntaxError, type FilterClause } from '../utils/filter.js'; @@ -23,6 +23,17 @@ const DEFAULT_PORT = 3000; const DEFAULT_PATH = '/'; const MAX_BODY_BYTES = 1_000_000; +/** + * P6: unified-envelope schema version shared by webhook and MQTT event output. + * + * The same key set now appears on both `events tail` (webhook) and + * `events mqtt-tail` (MQTT) output lines so downstream JSONL consumers can + * use a single parser regardless of source. Old fields are kept for one + * minor window so existing consumers keep working — see README and + * CHANGELOG for the deprecation schedule. + */ +export const EVENTS_SCHEMA_VERSION = '1'; + function extractEventId(parsed: unknown): string | null { if (!parsed || typeof parsed !== 'object') return null; const p = parsed as Record; @@ -32,22 +43,46 @@ function extractEventId(parsed: unknown): string | null { return null; } +function extractDeviceId(parsed: unknown): string | null { + if (!parsed || typeof parsed !== 'object') return null; + const p = parsed as Record; + const ctx = (p.context as Record | undefined) ?? p; + const mac = ctx.deviceMac; + if (typeof mac === 'string' && mac.length > 0) return mac; + const id = ctx.deviceId; + if (typeof id === 'string' && id.length > 0) return id; + return null; +} + interface EventRecord { + // Unified envelope (P6): also present on `events mqtt-tail` output so JSONL + // consumers can key on `source` + `kind` to discriminate without probing + // field presence. + schemaVersion: typeof EVENTS_SCHEMA_VERSION; + source: 'webhook'; + kind: 'event'; t: string; + eventId: string | null; + deviceId: string | null; + topic: string; // alias for `path` — unified across webhook & mqtt + payload: unknown; // alias for `body` — unified across webhook & mqtt + matchedKeys: string[]; // unified: clause keys that matched (empty = no filter or no match) + // Legacy (deprecated as of v2.7; removed in v3.0): remote: string; path: string; body: unknown; matched: boolean; } -function matchFilter( +function matchFilterDetail( body: unknown, clauses: FilterClause[] | null, -): boolean { - if (!clauses || clauses.length === 0) return true; - if (!body || typeof body !== 'object') return false; +): { matched: boolean; matchedKeys: string[] } { + if (!clauses || clauses.length === 0) return { matched: true, matchedKeys: [] }; + if (!body || typeof body !== 'object') return { matched: false, matchedKeys: [] }; const b = body as Record; const ctx = (b.context ?? b) as Record; + const hitKeys: string[] = []; for (const c of clauses) { let candidate: string; if (c.key === 'deviceId') { @@ -60,9 +95,10 @@ function matchFilter( const t = ctx.deviceType; candidate = typeof t === 'string' ? t : ''; } - if (!matchClause(candidate, c)) return false; + if (!matchClause(candidate, c)) return { matched: false, matchedKeys: [] }; + hitKeys.push(c.key); } - return true; + return { matched: true, matchedKeys: hitKeys }; } const EVENT_FILTER_KEYS = ['deviceId', 'type'] as const; @@ -123,11 +159,22 @@ export function startReceiver( } catch { // keep raw } - const matched = matchFilter(body, filter); + const { matched, matchedKeys } = matchFilterDetail(body, filter); + const t = new Date().toISOString(); + const urlPath = req.url ?? '/'; onEvent({ - t: new Date().toISOString(), + schemaVersion: EVENTS_SCHEMA_VERSION, + source: 'webhook', + kind: 'event', + t, + eventId: extractEventId(body), + deviceId: extractDeviceId(body), + topic: urlPath, + payload: body, + matchedKeys, + // Legacy mirror: remote: `${req.socket.remoteAddress ?? ''}:${req.socket.remotePort ?? ''}`, - path: req.url ?? '/', + path: urlPath, body, matched, }); @@ -162,9 +209,14 @@ SwitchBot posts events to a single webhook URL configured via: the port to the internet yourself (ngrok/cloudflared/reverse proxy) and point the SwitchBot webhook at that public URL. -Output (JSONL, one event per line): - { "t": "", "remote": "", "path": "/", - "body": , "matched": true } +Output (JSONL, one event per line; P6 unified envelope v2.7+): + { "schemaVersion": "1", "source": "webhook", "kind": "event", + "t": "", "eventId": , "deviceId": , + "topic": "/", // = path + "payload": , + "matchedKeys": ["deviceId"], // which filter clauses matched + // Legacy fields kept for one minor window (removed in v3.0): + "remote": "", "path": "/", "body": , "matched": true } Filter grammar: comma-separated clauses (AND-ed). Each clause is one of key=value — case-insensitive substring @@ -200,6 +252,9 @@ Examples: const forTimer = forMs !== null && forMs > 0 ? setTimeout(() => ac.abort(), forMs) : 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' }); await new Promise((resolve, reject) => { let server: http.Server | null = null; try { @@ -269,14 +324,20 @@ Connects to the SwitchBot MQTT service using your existing credentials (SWITCHBOT_TOKEN + SWITCHBOT_SECRET or ~/.switchbot/config.json). No additional MQTT configuration required. -Output (JSONL, one event per line): - { "t": "", "eventId": "", "topic": "", "payload": } - -Control records (interleaved, no "payload" field — use type-prefix to filter): - { "type": "__session_start", "at": "", "eventId": "", "state": "connecting" } before credential fetch (JSON mode only) - { "type": "__connect", "at": "", "eventId": "" } first successful connect - { "type": "__reconnect", "at": "", "eventId": "" } connect after a disconnect - { "type": "__disconnect", "at": "", "eventId": "" } reconnecting or failed +Output (JSONL, one event per line; P6 unified envelope v2.7+): + { "schemaVersion": "1", "source": "mqtt", "kind": "event", + "t": "", "eventId": "", "deviceId": , + "topic": "", + "payload": } + +Control records (interleaved, kind: "control" — filter by the "kind" field): + { "schemaVersion": "1", "source": "mqtt", "kind": "control", + "controlKind": "session_start"|"connect"|"reconnect"|"disconnect"|"heartbeat", + "t": "", "eventId": "", + "state": "connecting" // present on session_start only + // Legacy fields kept for one minor window (removed in v3.0): + "type": "__session_start"|"__connect"|"__reconnect"|"__disconnect", + "at": "" } Reconnect policy: the MQTT client retries with exponential backoff (1s → 30s capped, forever) while the credential is still valid; if the @@ -387,15 +448,26 @@ Examples: if (!isJsonMode()) { console.error('Fetching MQTT credentials from SwitchBot service…'); } + // 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' }); // 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. if (isJsonMode()) { + const sessionStartAt = new Date().toISOString(); printJson({ - type: '__session_start', - at: new Date().toISOString(), + schemaVersion: EVENTS_SCHEMA_VERSION, + source: 'mqtt', + kind: 'control', + controlKind: 'session_start', + t: sessionStartAt, eventId: crypto.randomUUID(), state: 'connecting', + // Legacy (deprecated as of v2.7; removed in v3.0): + type: '__session_start', + at: sessionStartAt, }); } const credential = await fetchMqttCredential(loaded.token, loaded.secret); @@ -435,7 +507,16 @@ Examples: // Default behavior: record history + print to stdout const { deviceId, deviceType } = parseSinkEvent(parsed); deviceHistoryStore.record(deviceId, msgTopic, deviceType, parsed, t); - const record = { t, eventId, topic: msgTopic, payload: parsed }; + const record = { + schemaVersion: EVENTS_SCHEMA_VERSION, + source: 'mqtt' as const, + kind: 'event' as const, + t, + eventId, + deviceId: deviceId ?? null, + topic: msgTopic, + payload: parsed, + }; if (isJsonMode()) { printJson(record); } else { @@ -452,7 +533,24 @@ Examples: let mqttFailed = false; let hasConnectedBefore = false; const emitControl = (kind: '__connect' | '__reconnect' | '__disconnect' | '__heartbeat'): void => { - const ctl = { type: kind, at: new Date().toISOString(), eventId: crypto.randomUUID() }; + const at = new Date().toISOString(); + const controlKindMap: Record = { + __connect: 'connect', + __reconnect: 'reconnect', + __disconnect: 'disconnect', + __heartbeat: 'heartbeat', + }; + const ctl = { + schemaVersion: EVENTS_SCHEMA_VERSION, + source: 'mqtt' as const, + kind: 'control' as const, + controlKind: controlKindMap[kind], + t: at, + eventId: crypto.randomUUID(), + // Legacy (deprecated as of v2.7; removed in v3.0): + type: kind, + at, + }; // Control events always go to stdout as JSONL so consumers that // filter real events by presence of `payload` can skip them. if (isJsonMode()) { diff --git a/src/commands/expand.ts b/src/commands/expand.ts index 08fa906..5fa48e9 100644 --- a/src/commands/expand.ts +++ b/src/commands/expand.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { intArg, stringArg } from '../utils/arg-parsers.js'; -import { handleError, isJsonMode, printJson, UsageError, emitJsonError } from '../utils/output.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 { isDryRun } from '../utils/flags.js'; @@ -114,18 +114,12 @@ Examples: if (!options.yes && !isDryRun() && isDestructiveCommand(deviceType, command, 'command')) { const reason = getDestructiveReason(deviceType, command, 'command'); - if (isJsonMode()) { - emitJsonError({ - code: 2, - kind: 'guard', - message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`, - hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.', - }); - } else { - console.error(`Refusing to run destructive command "${command}" without --yes.`); - if (reason) console.error(`Reason: ${reason}`); - } - process.exit(2); + exitWithError({ + code: 2, + kind: 'guard', + message: `"${command}" on ${deviceType || 'device'} is destructive and requires --yes.`, + hint: reason ? `Re-run with --yes. Reason: ${reason}` : 'Re-run with --yes to confirm.', + }); } const body = await executeCommand(deviceId, command, parameter, 'command'); diff --git a/src/commands/explain.ts b/src/commands/explain.ts index 513c56b..017d3ae 100644 --- a/src/commands/explain.ts +++ b/src/commands/explain.ts @@ -7,6 +7,7 @@ import { type InfraredDevice, } from '../lib/devices.js'; import type { DescribeResult } from '../lib/devices.js'; +import type { SafetyTier } from '../devices/catalog.js'; interface ExplainResult { deviceId: string; @@ -17,7 +18,14 @@ interface ExplainResult { readOnly: boolean; location?: { family?: string; room?: string }; liveStatus?: Record; - commands: Array<{ command: string; parameter: string; idempotent?: boolean; destructive?: boolean }>; + commands: Array<{ + command: string; + parameter: string; + idempotent?: boolean; + safetyTier?: SafetyTier; + /** @deprecated Derived from safetyTier === 'destructive'. Will be removed in v3.0. */ + destructive?: boolean; + }>; statusFields: string[]; children: Array<{ deviceId: string; name: string; type: string }>; suggestedActions: Array<{ command: string; parameter?: string; description: string }>; @@ -71,12 +79,16 @@ Examples: const caps = desc.capabilities; const commands = caps && 'commands' in caps - ? caps.commands.map((c) => ({ - command: c.command, - parameter: c.parameter, - idempotent: c.idempotent, - destructive: c.destructive, - })) + ? caps.commands.map((c) => { + const tier = (c as { safetyTier?: SafetyTier }).safetyTier; + return { + command: c.command, + parameter: c.parameter, + idempotent: c.idempotent, + ...(tier ? { safetyTier: tier } : {}), + destructive: c.destructive, + }; + }) : []; const statusFields = caps && 'statusFields' in caps ? caps.statusFields : []; const liveStatus = caps && 'liveStatus' in caps ? caps.liveStatus : undefined; diff --git a/src/commands/history.ts b/src/commands/history.ts index 84a54f7..2697e5d 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import path from 'node:path'; import os from 'node:os'; import { intArg, stringArg } from '../utils/arg-parsers.js'; -import { printJson, isJsonMode, handleError, UsageError, emitJsonError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, UsageError, exitWithError } from '../utils/output.js'; import { readAudit, verifyAudit, type AuditEntry } from '../utils/audit.js'; import { executeCommand } from '../lib/devices.js'; import { @@ -22,7 +22,7 @@ const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log'); export function registerHistoryCommand(program: Command): void { const history = program .command('history') - .description('View and replay commands recorded via --audit-log') + .description('View and replay SwitchBot commands recorded via --audit-log') .addHelpText('after', ` Every 'devices command' run with --audit-log is appended as JSONL to the audit file (default ~/.switchbot/audit.log). 'history show' prints the file, @@ -86,23 +86,19 @@ Examples: const entries = readAudit(file); const idx = Number(indexArg); if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) { - const msg = `Invalid index ${indexArg}. Log has ${entries.length} entries.`; - if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: msg }); - } else { - console.error(msg); - } - process.exit(2); + exitWithError({ + code: 2, + kind: 'usage', + message: `Invalid index ${indexArg}. Log has ${entries.length} entries.`, + }); } const entry: AuditEntry = entries[idx - 1]; if (entry.kind !== 'command') { - const msg = `Entry ${idx} is not a command (kind=${entry.kind}).`; - if (isJsonMode()) { - emitJsonError({ code: 2, kind: 'usage', message: msg }); - } else { - console.error(msg); - } - process.exit(2); + exitWithError({ + code: 2, + kind: 'usage', + message: `Entry ${idx} is not a command (kind=${entry.kind}).`, + }); } try { const result = await executeCommand( diff --git a/src/commands/identity.ts b/src/commands/identity.ts new file mode 100644 index 0000000..986a46a --- /dev/null +++ b/src/commands/identity.ts @@ -0,0 +1,62 @@ +/** + * Single source of truth for SwitchBot product identity. + * + * Consumed by: + * - `program.description()` / `--help` (via PRODUCT_TAGLINE in src/index.ts) + * - `--help --json` root (via src/utils/help-json.ts) + * - `switchbot capabilities` / `--json` (identity block) + * - `switchbot agent-bootstrap --json` (identity block) + * + * Keeping this in one file prevents drift between those four surfaces. + * + * IMPORTANT: the SwitchBot CLI only talks to the SwitchBot Cloud API over + * HTTPS. It does NOT drive BLE radios directly — BLE-only devices are + * reached by going through a SwitchBot Hub, which the Cloud API already + * handles transparently. Please do not reintroduce the word "BLE" into the + * tagline / README: it is misleading for AI agents reading `--help`. + */ +export const IDENTITY = { + product: 'SwitchBot', + domain: 'IoT smart home device control', + vendor: 'Wonderlabs, Inc.', + apiVersion: 'v1.1', + apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI', + // Product category keywords. AI agents scan these to judge scope + // ("does SwitchBot control door locks? air conditioners?") without + // parsing the full device catalog. + productCategories: [ + 'lights (bulbs / strips / color)', + 'locks / keypads', + 'curtains / blinds / shades', + 'sensors (motion / contact / climate / water-leak)', + 'plugs / strips', + 'bots / mechanical pushers', + 'robot vacuums', + 'IR appliances via Hub (TV / AC / fan / projector)', + ] as const, + deviceCategories: { + physical: + 'Wi-Fi-connected and Hub-mediated devices — controlled via Cloud API (CLI does not drive BLE directly)', + ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, fan, etc.)', + }, + constraints: { + quotaPerDay: 10000, + hubRequiredForBle: true, + transport: 'Cloud API v1.1 (HTTPS)', + authMethod: 'HMAC-SHA256 token+secret', + }, + agentGuide: 'docs/agent-guide.md', +} as const; + +/** + * One-line product description used for `program.description()` (the first + * line an AI agent sees when running `switchbot --help`). + * + * Structure: "SwitchBot smart home CLI — via ; + * ." Keep categories in sync with + * IDENTITY.productCategories above. + */ +export const PRODUCT_TAGLINE = + 'SwitchBot smart home CLI — control lights, locks, curtains, sensors, plugs, ' + + 'and IR appliances (TV/AC/fan) via Cloud API v1.1; run scenes, stream real-time ' + + 'events, and integrate AI agents via MCP.'; diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 94c8bd4..3a24691 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -4,7 +4,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; import { intArg, stringArg } from '../utils/arg-parsers.js'; -import { handleError, isJsonMode, buildErrorPayload, emitJsonError, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; +import { handleError, isJsonMode, buildErrorPayload, exitWithError, type ErrorPayload, type ErrorSubKind } from '../utils/output.js'; import { VERSION } from '../version.js'; import { fetchDeviceList, @@ -21,7 +21,11 @@ import { toMcpIrDeviceShape, } from '../lib/devices.js'; import { fetchScenes, executeScene } from '../lib/scenes.js'; -import { findCatalogEntry } from '../devices/catalog.js'; +import { + findCatalogEntry, + deriveSafetyTier, + getCommandSafetyReason, +} from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; import { validateParameter } from '../devices/param-validator.js'; import { EventSubscriptionManager } from '../mcp/events-subscription.js'; @@ -448,7 +452,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, command: effectiveCommand, deviceType: typeName, description: spec?.description ?? null, - ...(reason ? { destructiveReason: reason } : {}), + ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}), }, }, ); @@ -632,6 +636,8 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, description: z.string(), commandType: z.enum(['command', 'customize']).optional(), idempotent: z.boolean().optional(), + safetyTier: z.enum(['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']).optional(), + safetyReason: z.string().optional(), destructive: z.boolean().optional(), }).passthrough()), aliases: z.array(z.string()).optional(), @@ -654,9 +660,22 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, ); } const hits = searchCatalog(query, limit); - const structured = { results: hits as unknown as Array>, total: hits.length }; + const normalised = hits.map((e) => ({ + ...e, + commands: e.commands.map((c) => { + const tier = deriveSafetyTier(c, e); + const reason = getCommandSafetyReason(c); + return { + ...c, + safetyTier: tier, + destructive: tier === 'destructive', + ...(reason ? { safetyReason: reason } : {}), + }; + }), + })); + const structured = { results: normalised as unknown as Array>, total: normalised.length }; return { - content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(normalised, null, 2) }], structuredContent: structured, }; } @@ -723,16 +742,69 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, _meta: { agentSafetyTier: 'read' }, inputSchema: z .object({ - deviceId: z.string().min(1), - since: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), - metrics: z.array(z.string().min(1)).min(1), - aggs: z.array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])).optional(), - bucket: z.string().optional(), - maxBucketSamples: z.number().int().positive().max(MAX_SAMPLE_CAP).optional(), + deviceId: z.string().min(1).describe('Device ID to aggregate over (must exist in ~/.switchbot/device-history/).'), + since: z + .string() + .optional() + .describe('Relative window ending now, e.g. "30s", "15m", "1h", "7d". Mutually exclusive with from/to.'), + from: z.string().optional().describe('Range start (ISO-8601). Requires `to`.'), + to: z.string().optional().describe('Range end (ISO-8601). Requires `from`.'), + metrics: z + .array(z.string().min(1)) + .min(1) + .describe('One or more numeric payload field names to aggregate (e.g. ["temperature","humidity"]).'), + aggs: z + .array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])) + .optional() + .describe('Aggregation functions to apply per metric (default: ["count","avg"]).'), + bucket: z + .string() + .optional() + .describe('Bucket width like "5m", "1h", "1d". Omit for a single bucket spanning the full range.'), + maxBucketSamples: z + .number() + .int() + .positive() + .max(MAX_SAMPLE_CAP) + .optional() + .describe(`Sample cap per bucket to bound memory (default ${10_000}, max ${MAX_SAMPLE_CAP}). partial=true in the result when any bucket was capped.`), }) .strict(), + outputSchema: { + deviceId: z.string(), + bucket: z.string().optional().describe('Bucket width echoed back when specified; omitted for single-bucket results.'), + from: z.string().describe('Effective range start (ISO-8601).'), + to: z.string().describe('Effective range end (ISO-8601).'), + metrics: z.array(z.string()).describe('Metrics that were requested.'), + aggs: z + .array(z.enum(ALL_AGG_FNS as unknown as [AggFn, ...AggFn[]])) + .describe('Aggregation functions that were applied.'), + buckets: z + .array( + z.object({ + t: z.string().describe('Bucket start timestamp (ISO-8601).'), + metrics: z + .record( + z.string(), + z + .object({ + count: z.number().optional(), + min: z.number().optional(), + max: z.number().optional(), + avg: z.number().optional(), + sum: z.number().optional(), + p50: z.number().optional(), + p95: z.number().optional(), + }) + .describe('Per-aggregate function result for this metric in this bucket.'), + ) + .describe('Per-metric result keyed by metric name.'), + }), + ) + .describe('Time-ordered buckets; empty when no records match.'), + partial: z.boolean().describe('True if any bucket was sample-capped; retry with a higher maxBucketSamples or a narrower range for exact values.'), + notes: z.array(z.string()).describe('Human-readable notes about the aggregation (e.g. "metric X is non-numeric").'), + }, }, async (args) => { const opts: AggOptions = { @@ -745,9 +817,20 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, maxBucketSamples: args.maxBucketSamples, }; const res = await aggregateDeviceHistory(args.deviceId, opts); + const structured: Record = { + deviceId: res.deviceId, + from: res.from, + to: res.to, + metrics: res.metrics, + aggs: res.aggs, + buckets: res.buckets, + partial: res.partial, + notes: res.notes, + }; + if (res.bucket !== undefined) structured.bucket = res.bucket; return { content: [{ type: 'text', text: JSON.stringify(res, null, 2) }], - structuredContent: res as unknown as Record, + structuredContent: structured, }; }, ); @@ -880,6 +963,18 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, return server; } +/** + * P10: list the tool names registered on an McpServer instance. Used by + * `doctor`'s dry-run check. The MCP SDK keeps `_registeredTools` private, + * so we reach through a narrow cast — safe because this only runs in + * diagnostic code and the shape is stable across SDK versions. + */ +export function listRegisteredTools(server: McpServer): string[] { + const internal = server as unknown as { _registeredTools?: Record }; + if (!internal._registeredTools) return []; + return Object.keys(internal._registeredTools).sort(); +} + export function registerMcpCommand(program: Command): void { const mcp = program .command('mcp') diff --git a/src/commands/plan.ts b/src/commands/plan.ts index 16e79f6..c227e76 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -217,7 +217,7 @@ interface PlanRunResult { export function registerPlanCommand(program: Command): void { const plan = program .command('plan') - .description('Agent-authored batch plans: schema, validate, run') + .description('Author, validate, and run SwitchBot batch plans (JSON schema for AI agents)') .addHelpText('after', ` A "plan" is a JSON document describing a sequence of commands/scenes/waits. The schema is fixed — agents emit plans, the CLI executes them. No LLM inside. diff --git a/src/commands/schema.ts b/src/commands/schema.ts index d3f0d43..49c6a2b 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,7 +1,15 @@ import { Command } from 'commander'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { printJson } from '../utils/output.js'; -import { getEffectiveCatalog, type CommandSpec, type DeviceCatalogEntry } from '../devices/catalog.js'; +import { + getEffectiveCatalog, + deriveSafetyTier, + getCommandSafetyReason, + type CommandSpec, + type DeviceCatalogEntry, + type SafetyTier, +} from '../devices/catalog.js'; +import { RESOURCE_CATALOG } from '../devices/resources.js'; import { loadCache } from '../devices/cache.js'; interface SchemaEntry { @@ -17,7 +25,10 @@ interface SchemaEntry { description: string; commandType: 'command' | 'customize'; idempotent: boolean; + safetyTier: SafetyTier; + /** @deprecated Derived from safetyTier === 'destructive'. Will be removed in v3.0. */ destructive: boolean; + safetyReason?: string; exampleParams?: string[]; }>; statusFields: string[]; @@ -33,6 +44,8 @@ interface CompactSchemaEntry { parameter: string; commandType: 'command' | 'customize'; idempotent: boolean; + safetyTier: SafetyTier; + /** @deprecated Derived from safetyTier === 'destructive'. Will be removed in v3.0. */ destructive: boolean; }>; statusFields: string[]; @@ -46,19 +59,23 @@ function toSchemaEntry(e: DeviceCatalogEntry): SchemaEntry { aliases: e.aliases ?? [], role: e.role ?? null, readOnly: e.readOnly ?? false, - commands: e.commands.map(toSchemaCommand), + commands: e.commands.map((c) => toSchemaCommand(c, e)), statusFields: e.statusFields ?? [], }; } -function toSchemaCommand(c: CommandSpec) { +function toSchemaCommand(c: CommandSpec, entry: DeviceCatalogEntry) { + const tier = deriveSafetyTier(c, entry); + const reason = getCommandSafetyReason(c); return { command: c.command, parameter: c.parameter, description: c.description, commandType: (c.commandType ?? 'command') as 'command' | 'customize', idempotent: Boolean(c.idempotent), - destructive: Boolean(c.destructive), + safetyTier: tier, + destructive: tier === 'destructive', + ...(reason ? { safetyReason: reason } : {}), ...(c.exampleParams ? { exampleParams: c.exampleParams } : {}), }; } @@ -69,13 +86,17 @@ function toCompactEntry(e: DeviceCatalogEntry): CompactSchemaEntry { category: e.category, role: e.role ?? null, readOnly: e.readOnly ?? false, - commands: e.commands.map((c) => ({ - command: c.command, - parameter: c.parameter, - commandType: (c.commandType ?? 'command') as 'command' | 'customize', - idempotent: Boolean(c.idempotent), - destructive: Boolean(c.destructive), - })), + commands: e.commands.map((c) => { + const tier = deriveSafetyTier(c, e); + return { + command: c.command, + parameter: c.parameter, + commandType: (c.commandType ?? 'command') as 'command' | 'customize', + idempotent: Boolean(c.idempotent), + safetyTier: tier, + destructive: tier === 'destructive', + }; + }), statusFields: e.statusFields ?? [], }; } @@ -93,7 +114,7 @@ export function registerSchemaCommand(program: Command): void { const CATEGORIES = ['physical', 'ir'] as const; const schema = program .command('schema') - .description('Export the device catalog as structured JSON (for agent prompts / tooling)'); + .description('Export the SwitchBot device catalog as structured JSON (for AI agent prompts / tooling)'); schema .command('export') @@ -190,6 +211,7 @@ Examples: }; if (!options.compact) { payload.generatedAt = new Date().toISOString(); + payload.resources = RESOURCE_CATALOG; payload.cliAddedFields = [ { field: '_fetchedAt', diff --git a/src/commands/watch.ts b/src/commands/watch.ts index b3d2f45..cc4b338 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,11 +1,12 @@ import { Command } from 'commander'; -import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js'; import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; import { parseDurationToMs, getFields } from '../utils/flags.js'; import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js'; import { createClient } from '../api/client.js'; import { resolveDeviceId } from '../utils/name-resolver.js'; +import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js'; const DEFAULT_INTERVAL_MS = 30_000; const MIN_INTERVAL_MS = 1_000; @@ -137,7 +138,15 @@ Examples: const forMs = options.for ? parseDurationToMs(options.for) : null; - const fields: string[] | null = getFields() ?? null; + const rawFields: string[] | null = getFields() ?? null; + // Resolve aliases upfront against the static canonical registry. + // Validating here lets UsageError exit the command before any + // polling starts, and keeps mid-loop error handling free of + // "misuse" concerns. Unknown fields that are not registered as + // aliases but happen to match an API key pass through unchanged. + const fields: string[] | null = rawFields + ? resolveFieldList(rawFields, listAllCanonical()) + : null; const ac = new AbortController(); const onSig = () => ac.abort(); @@ -147,6 +156,10 @@ Examples: ? setTimeout(() => ac.abort(), forMs) : null; + // P7: streaming JSON contract — first line under --json is the + // stream header so consumers can route by eventKind/cadence. + if (isJsonMode()) emitStreamHeader({ eventKind: 'tick', cadence: 'poll' }); + try { const prev = new Map>(); const client = createClient(); diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 43983de..dd75862 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -7,23 +7,67 @@ * - CommandSpec.idempotent: repeat-safe — calling it N times ends in the * same state as calling it once (turnOn, setBrightness 50). Agents can * retry these freely. Counter-examples: toggle, press, volumeAdd. - * - CommandSpec.destructive: causes a real-world effect that is hard or - * unsafe to reverse (unlock, garage open, deleteKey). UIs and agents - * should require explicit confirmation before issuing these. + * - CommandSpec.safetyTier: explicit action safety classification. See + * SafetyTier for the 5-tier enum. Built-in entries set this on the + * destructive tier; other tiers are derived (see deriveSafetyTier). + * - CommandSpec.destructive (deprecated, v3.0 removal): legacy boolean + * that maps to safetyTier === 'destructive'. Still accepted in + * ~/.switchbot/catalog.json overlays and derived into safetyTier. * - DeviceCatalogEntry.role: functional grouping for filter/search * ("all lighting", "all security"). Does not affect API behavior. * - DeviceCatalogEntry.readOnly: the device has no control commands; it * can only be queried via 'devices status'. */ +/** + * Catalog shape version. Bump when any of the exported interfaces + * (CommandSpec / DeviceCatalogEntry / SafetyTier) gain/lose/rename a + * load-bearing field. The agent-bootstrap payload's schemaVersion must + * stay pinned to this value; `doctor` fails the `catalog-schema` check + * when they drift. + */ +export const CATALOG_SCHEMA_VERSION = '1.0'; + +/** + * Safety classification for catalog commands. + * + * - 'read' —— Read-only query (status fetch). Reserved for v2.8+ + * `statusQueries` expansion; no command uses it today. + * - 'mutation' —— Causes a state change but is reversible/idempotent + * (turnOn/Off, setBrightness, setPosition). + * - 'ir-fire-forget' —— IR command (no reply/ack) or customize IR button. + * Fire-and-forget; reversibility depends on device. + * - 'destructive' —— Hard or unsafe to reverse; physical-world side effects + * (unlock, garage open, deleteKey). Needs confirmation. + * - 'maintenance' —— Factory reset / firmware update / deep calibrate. + * Reserved; the SwitchBot API exposes no such endpoint + * today, so no command uses it. + */ +export type SafetyTier = + | 'read' + | 'mutation' + | 'ir-fire-forget' + | 'destructive' + | 'maintenance'; + export interface CommandSpec { command: string; parameter: string; description: string; commandType?: 'command' | 'customize'; idempotent?: boolean; + /** + * Explicit safety tier. When omitted, deriveSafetyTier() infers: + * destructive: true → 'destructive' + * commandType: 'customize' or entry.category === 'ir' → 'ir-fire-forget' + * otherwise → 'mutation' + */ + safetyTier?: SafetyTier; + /** One sentence explaining *why* this command needs caution — used in guard errors. */ + safetyReason?: string; + /** @deprecated Since v2.7 — use safetyTier: 'destructive'. Will be removed in v3.0. */ destructive?: boolean; - /** One sentence explaining *why* this command is destructive — used in guard errors so agents/users can decide whether to confirm. */ + /** @deprecated Since v2.7 — use safetyReason. Will be removed in v3.0. */ destructiveReason?: string; exampleParams?: string[]; } @@ -49,10 +93,104 @@ export interface DeviceCatalogEntry { aliases?: string[]; commands: CommandSpec[]; statusFields?: string[]; + /** + * P11: strongly-typed read-only queries powering the 'read' safety tier. + * When omitted, deriveStatusQueries() produces equivalent entries from + * `statusFields`. Use this to override descriptions or attach examples. + */ + statusQueries?: ReadOnlyQuerySpec[]; role?: DeviceRole; readOnly?: boolean; } +/** + * P11: a single read-only query against a device. `endpoint: 'status'` is + * the normal /devices/{id}/status call; 'keys' reads lock keypad entries; + * 'webhook' reads the server-side webhook event subscription. All three + * are safe to call at any time — they never mutate state. + */ +export interface ReadOnlyQuerySpec { + field: string; + description: string; + endpoint: 'status' | 'keys' | 'webhook'; + safetyTier: 'read'; + example?: unknown; +} + +/** + * Human-readable descriptions for common status fields. Populated from + * the SwitchBot API v1.1 docs. Used by deriveStatusQueries() so every + * query has a meaningful description even when the entry itself only + * declares the field name. + */ +const STATUS_FIELD_DESCRIPTIONS: Record = { + power: 'Power state (on/off)', + battery: 'Battery percentage (0-100)', + version: 'Firmware version string', + temperature: 'Ambient temperature (°C)', + humidity: 'Ambient humidity (% RH)', + CO2: 'CO2 concentration (ppm)', + brightness: 'Current brightness (0-100)', + color: 'Current RGB color (r:g:b)', + colorTemperature: 'Color temperature in Kelvin', + mode: 'Operating mode', + deviceMode: 'Hardware mode (Bot-specific)', + lockState: 'Lock state (locked/unlocked)', + doorState: 'Door contact state (open/closed)', + calibrate: 'Calibration status', + moving: 'Motion in progress (boolean)', + slidePosition: 'Slide position (0-100)', + group: 'Multi-device group membership', + direction: 'Tilt direction', + voltage: 'Line voltage', + electricCurrent: 'Instantaneous current draw', + electricityOfDay: 'kWh consumed today', + usedElectricity: 'Cumulative kWh', + useTime: 'Total runtime (seconds)', + weight: 'Load / weight reading', + switchStatus: 'Relay state (integer encoded)', + switch1Status: 'Channel 1 relay state', + switch2Status: 'Channel 2 relay state', + workingStatus: 'Device working status (vacuum/purifier)', + onlineStatus: 'Online / offline (string)', + online: 'Online / offline (boolean or int)', + taskType: 'Current task identifier', + nightStatus: 'Night-mode status', + oscillation: 'Horizontal oscillation on/off', + verticalOscillation: 'Vertical oscillation on/off', + chargingStatus: 'Charging (boolean)', + fanSpeed: 'Current fan speed level', + nebulizationEfficiency: 'Humidifier mist level', + childLock: 'Child-lock engaged', + sound: 'Beep / audio feedback enabled', + lackWater: 'Water tank low (boolean)', + filterElement: 'Filter life remaining', + auto: 'Auto mode enabled', + targetTemperature: 'Thermostat target temperature', + moveDetected: 'Motion detected (boolean)', + openState: 'Contact sensor open/closed', + status: 'Device-specific status word', + lightLevel: 'Ambient light level', +}; + +/** + * P11: derive the read-only query list for an entry. If the entry has + * explicit `statusQueries`, return them as-is; otherwise synthesize one + * ReadOnlyQuerySpec per `statusFields` entry, all keyed to the `status` + * endpoint. IR-category entries have no status channel so return []. + */ +export function deriveStatusQueries(entry: DeviceCatalogEntry): ReadOnlyQuerySpec[] { + if (entry.statusQueries && entry.statusQueries.length > 0) return entry.statusQueries; + if (entry.category === 'ir') return []; + const fields = entry.statusFields ?? []; + return fields.map((f) => ({ + field: f, + description: STATUS_FIELD_DESCRIPTIONS[f] ?? `${f} (see API docs)`, + endpoint: 'status', + safetyTier: 'read', + })); +} + // ---- Command fragments (reused across entries) ------------------------- const onOff: CommandSpec[] = [ @@ -104,7 +242,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ aliases: ['Smart Lock Pro'], commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, - { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt', idempotent: true }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], @@ -116,7 +254,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ role: 'security', commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, - { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], }, @@ -127,7 +265,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ role: 'security', commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, - { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' }, { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], @@ -346,8 +484,8 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ description: 'Cloud-connected garage door controller; turnOn opens and turnOff closes the door.', role: 'security', commands: [ - { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, destructive: true, destructiveReason: 'Opens the garage door — anyone nearby can enter the space.' }, - { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, destructive: true, destructiveReason: 'Closes the garage door — verify no person or obstacle is in the way.' }, + { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Opens the garage door — anyone nearby can enter the space.' }, + { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Closes the garage door — verify no person or obstacle is in the way.' }, ], statusFields: ['switchStatus', 'version', 'online'], }, @@ -369,8 +507,8 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ role: 'security', aliases: ['Keypad Touch'], commands: [ - { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":,"endTime":}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, destructive: true, destructiveReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' }, - { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, destructive: true, destructiveReason: 'Permanently removes a passcode — the holder immediately loses door access.' }, + { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":,"endTime":}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, safetyTier: 'destructive', safetyReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' }, + { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' }, ], statusFields: ['version'], }, @@ -584,6 +722,33 @@ export function findCatalogEntry(query: string): DeviceCatalogEntry | DeviceCata return matches; } +/** + * Derive the safety tier for a catalog command, honouring an explicit + * `safetyTier` when present and falling back to heuristic inference. + * + * The inference order is: + * 1. Explicit `spec.safetyTier`. + * 2. Legacy `spec.destructive: true` → `'destructive'` (overlay compat). + * 3. IR context (customize command OR entry.category === 'ir') + * → `'ir-fire-forget'`. + * 4. Default → `'mutation'`. + */ +export function deriveSafetyTier( + spec: CommandSpec, + entry?: Pick, +): SafetyTier { + if (spec.safetyTier) return spec.safetyTier; + if (spec.destructive) return 'destructive'; + if (spec.commandType === 'customize') return 'ir-fire-forget'; + if (entry?.category === 'ir') return 'ir-fire-forget'; + return 'mutation'; +} + +/** Read the safety reason for a command, with fallback to the legacy field. */ +export function getCommandSafetyReason(spec: CommandSpec): string | null { + return spec.safetyReason ?? spec.destructiveReason ?? null; +} + /** * Pick up to 3 non-destructive, idempotent commands an agent can safely invoke * to explore or exercise a device. Used by `devices describe --json` to hint @@ -595,7 +760,10 @@ export function suggestedActions(entry: DeviceCatalogEntry): Array<{ description: string; }> { const safe = entry.commands.filter( - (c) => c.idempotent === true && !c.destructive && c.commandType !== 'customize' + (c) => + c.idempotent === true && + deriveSafetyTier(c, entry) !== 'destructive' && + c.commandType !== 'customize', ); const picks: CommandSpec[] = []; const seen = new Set(); diff --git a/src/devices/resources.ts b/src/devices/resources.ts new file mode 100644 index 0000000..1c4aeb9 --- /dev/null +++ b/src/devices/resources.ts @@ -0,0 +1,336 @@ +/** + * Declarative metadata for non-device resources exposed by the SwitchBot API: + * scenes, webhooks, and keypad credentials ("keys"). + * + * Consumed by `capabilities --json` and `schema export` so AI agents can + * discover these surfaces the same way they discover device commands. + * + * Scope: + * - Descriptive metadata only (no runtime execution — CLI/MCP handlers stay + * source-of-truth for behavior). + * - Webhook event list is derived from the device catalog and is advisory — + * not every SwitchBot device actually pushes every listed event; refer to + * the SwitchBot webhook docs for authoritative shapes. + */ + +export type ResourceSafetyTier = 'read' | 'mutation' | 'destructive'; + +export interface SceneOperation { + verb: 'list' | 'execute' | 'describe'; + method: 'GET' | 'POST'; + endpoint: string; + params: ReadonlyArray<{ name: string; required: boolean; type: string }>; + safetyTier: ResourceSafetyTier; +} + +export interface SceneSpec { + description: string; + operations: ReadonlyArray; +} + +export interface WebhookEndpoint { + verb: 'setup' | 'query' | 'update' | 'delete'; + method: 'POST'; + path: string; + safetyTier: ResourceSafetyTier; + requiredParams: ReadonlyArray; +} + +export interface WebhookEventField { + name: string; + type: 'string' | 'number' | 'boolean' | 'timestamp'; + description: string; + example?: unknown; +} + +export interface WebhookEventSpec { + eventType: string; + devicePattern: string; + fields: ReadonlyArray; +} + +export interface WebhookCatalog { + endpoints: ReadonlyArray; + events: ReadonlyArray; + constraints: { + maxUrlLength: number; + maxWebhooksPerAccount: number; + }; +} + +export interface KeySpec { + keyType: 'permanent' | 'timeLimit' | 'disposable' | 'urgent'; + description: string; + requiredParams: ReadonlyArray; + optionalParams: ReadonlyArray; + supportedDevices: ReadonlyArray; + safetyTier: 'destructive'; +} + +export interface ResourceCatalog { + scenes: SceneSpec; + webhooks: WebhookCatalog; + keys: ReadonlyArray; +} + +const COMMON_WEBHOOK_FIELDS: ReadonlyArray = [ + { name: 'deviceType', type: 'string', description: 'SwitchBot device type string', example: 'WoMeter' }, + { name: 'deviceMac', type: 'string', description: 'Bluetooth MAC address (uppercase, colon-separated)', example: 'AA:BB:CC:11:22:33' }, + { name: 'timeOfSample', type: 'timestamp', description: 'Millisecond Unix timestamp when the sample was taken', example: 1700000000000 }, +]; + +export const RESOURCE_CATALOG: ResourceCatalog = { + scenes: { + description: 'Manual scenes (IFTTT-style rules) authored in the SwitchBot app. Execution is fire-and-forget from the cloud — side-effects happen on the user\'s devices.', + operations: [ + { + verb: 'list', + method: 'GET', + endpoint: '/v1.1/scenes', + params: [], + safetyTier: 'read', + }, + { + verb: 'execute', + method: 'POST', + endpoint: '/v1.1/scenes/{sceneId}/execute', + params: [{ name: 'sceneId', required: true, type: 'string' }], + safetyTier: 'mutation', + }, + { + verb: 'describe', + method: 'GET', + endpoint: '/v1.1/scenes/{sceneId}', + params: [{ name: 'sceneId', required: true, type: 'string' }], + safetyTier: 'read', + }, + ], + }, + + webhooks: { + endpoints: [ + { + verb: 'setup', + method: 'POST', + path: '/v1.1/webhook/setupWebhook', + safetyTier: 'mutation', + requiredParams: ['url'], + }, + { + verb: 'query', + method: 'POST', + path: '/v1.1/webhook/queryWebhook', + safetyTier: 'read', + requiredParams: ['action'], + }, + { + verb: 'update', + method: 'POST', + path: '/v1.1/webhook/updateWebhook', + safetyTier: 'mutation', + requiredParams: ['url', 'enable'], + }, + { + verb: 'delete', + method: 'POST', + path: '/v1.1/webhook/deleteWebhook', + safetyTier: 'destructive', + requiredParams: ['url'], + }, + ], + events: [ + { + eventType: 'WoMeter', + devicePattern: 'Meter / Meter Plus / Indoor-Outdoor Meter', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius', example: 22.5 }, + { name: 'humidity', type: 'number', description: 'Relative humidity (%)', example: 45 }, + { name: 'battery', type: 'number', description: 'Battery remaining (%)', example: 88 }, + ], + }, + { + eventType: 'WoCO2Sensor', + devicePattern: 'CO2 Monitor', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'CO2', type: 'number', description: 'CO2 concentration in ppm', example: 520 }, + { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' }, + { name: 'humidity', type: 'number', description: 'Relative humidity (%)' }, + ], + }, + { + eventType: 'WoPresence', + devicePattern: 'Motion Sensor / Video Doorbell motion', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'detectionState', type: 'string', description: 'Detection result word', example: 'DETECTED' }, + ], + }, + { + eventType: 'WoContact', + devicePattern: 'Contact Sensor', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'openState', type: 'string', description: 'Door/window state', example: 'open' }, + { name: 'moveDetected', type: 'boolean', description: 'Motion detected during this sample' }, + ], + }, + { + eventType: 'WoLock', + devicePattern: 'Smart Lock / Smart Lock Lite / Smart Lock Pro', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'lockState', type: 'string', description: 'Lock state: locked, unlocked, jammed', example: 'locked' }, + { name: 'battery', type: 'number', description: 'Battery remaining (%)' }, + ], + }, + { + eventType: 'WoPlug', + devicePattern: 'Plug Mini / Plug / Relay Switch', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'power', type: 'string', description: 'Power state (on/off)', example: 'on' }, + { name: 'voltage', type: 'number', description: 'Instantaneous voltage (V)' }, + { name: 'electricCurrent', type: 'number', description: 'Instantaneous current (A)' }, + ], + }, + { + eventType: 'WoBot', + devicePattern: 'Bot', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'power', type: 'string', description: 'Power state (on/off)' }, + { name: 'battery', type: 'number', description: 'Battery remaining (%)' }, + ], + }, + { + eventType: 'WoCurtain', + devicePattern: 'Curtain / Blind Tilt / Roller Shade', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'slidePosition', type: 'number', description: 'Current slide position (0–100)' }, + { name: 'calibrate', type: 'boolean', description: 'True if device is calibrated' }, + ], + }, + { + eventType: 'WoDoorbell', + devicePattern: 'Video Doorbell button press', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'buttonName', type: 'string', description: 'Identifier of the pressed button' }, + { name: 'pressedAt', type: 'timestamp', description: 'Press timestamp in milliseconds' }, + ], + }, + { + eventType: 'WoKeypad', + devicePattern: 'Keypad scan / createKey result / deleteKey result', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'eventType', type: 'string', description: 'Sub-event (createKey / deleteKey / invalidCode)' }, + { name: 'commandId', type: 'string', description: 'Correlation id returned by the original command' }, + { name: 'result', type: 'string', description: 'Outcome (success / failed / timeout)' }, + ], + }, + { + eventType: 'WoColorBulb', + devicePattern: 'Color Bulb', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'power', type: 'string', description: 'Power state (on/off)' }, + { name: 'brightness', type: 'number', description: 'Brightness (0–100)' }, + { name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' }, + { name: 'colorTemperature', type: 'number', description: 'Color temperature in Kelvin' }, + ], + }, + { + eventType: 'WoStrip', + devicePattern: 'Strip Light', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'power', type: 'string', description: 'Power state (on/off)' }, + { name: 'brightness', type: 'number', description: 'Brightness (0–100)' }, + { name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' }, + ], + }, + { + eventType: 'WoSweeper', + devicePattern: 'Robot Vacuum', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'workingStatus', type: 'string', description: 'Cleaning state' }, + { name: 'battery', type: 'number', description: 'Battery remaining (%)' }, + { name: 'taskType', type: 'string', description: 'Current task (standby / clean / charge)' }, + ], + }, + { + eventType: 'WoWaterLeakDetect', + devicePattern: 'Water Leak Detector', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'waterLeakDetect', type: 'number', description: 'Leak flag (0 = dry, 1 = leak detected)' }, + { name: 'battery', type: 'number', description: 'Battery remaining (%)' }, + ], + }, + { + eventType: 'WoHub', + devicePattern: 'Hub 2 / Hub 3 (ambient sensors)', + fields: [ + ...COMMON_WEBHOOK_FIELDS, + { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' }, + { name: 'humidity', type: 'number', description: 'Relative humidity (%)' }, + { name: 'lightLevel', type: 'number', description: 'Illuminance level' }, + ], + }, + ], + constraints: { + maxUrlLength: 2048, + maxWebhooksPerAccount: 1, + }, + }, + + keys: [ + { + keyType: 'permanent', + description: 'Passcode that never expires — valid until manually deleted.', + requiredParams: ['name', 'password'], + optionalParams: [], + supportedDevices: ['Keypad', 'Keypad Touch'], + safetyTier: 'destructive', + }, + { + keyType: 'timeLimit', + description: 'Passcode valid only between startTime and endTime (Unix seconds).', + requiredParams: ['name', 'password', 'startTime', 'endTime'], + optionalParams: [], + supportedDevices: ['Keypad', 'Keypad Touch'], + safetyTier: 'destructive', + }, + { + keyType: 'disposable', + description: 'Passcode that can be used once and then auto-expires.', + requiredParams: ['name', 'password'], + optionalParams: ['startTime', 'endTime'], + supportedDevices: ['Keypad', 'Keypad Touch'], + safetyTier: 'destructive', + }, + { + keyType: 'urgent', + description: 'Emergency passcode (typically tied to panic / audit workflow).', + requiredParams: ['name', 'password'], + optionalParams: [], + supportedDevices: ['Keypad', 'Keypad Touch'], + safetyTier: 'destructive', + }, + ], +}; + +/** Convenience: return the list of known webhook event types. */ +export function listWebhookEventTypes(): string[] { + return RESOURCE_CATALOG.webhooks.events.map((e) => e.eventType); +} + +/** Convenience: return the list of supported keypad key types. */ +export function listKeyTypes(): string[] { + return RESOURCE_CATALOG.keys.map((k) => k.keyType); +} diff --git a/src/index.ts b/src/index.ts index a37d5e8..eb0a484 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,9 @@ import { createRequire } from 'node:module'; import chalk from 'chalk'; import { intArg, stringArg, enumArg } from './utils/arg-parsers.js'; import { parseDurationToMs } from './utils/flags.js'; -import { emitJsonError, isJsonMode } from './utils/output.js'; +import { emitJsonError, isJsonMode, printJson } from './utils/output.js'; +import { commandToJson, resolveTargetCommand } from './utils/help-json.js'; +import { PRODUCT_TAGLINE } from './commands/identity.js'; import { registerConfigCommand } from './commands/config.js'; import { registerDevicesCommand } from './commands/devices.js'; import { registerScenesCommand } from './commands/scenes.js'; @@ -62,7 +64,7 @@ const cacheModeArg = (value: string): string => { program .name('switchbot') - .description('Command-line tool for SwitchBot API v1.1') + .description(PRODUCT_TAGLINE) .version(pkgVersion) .option('--no-color', 'Disable ANSI colors in output') .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') @@ -164,6 +166,11 @@ function enableSuggestions(cmd: Command): void { } enableSuggestions(program); +// In JSON mode suppress the plain-text help output so we can emit structured JSON instead. +if (isJsonMode()) { + program.configureOutput({ writeOut: () => {} }); +} + try { await program.parseAsync(); } catch (err) { @@ -171,7 +178,14 @@ try { // argParser on a subcommand option) don't always hit the root exitOverride. // Mirror the root mapping so all usage errors surface as exit 2. if (err instanceof CommanderError) { - if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') { + if (err.code === 'commander.helpDisplayed') { + if (isJsonMode()) { + const target = resolveTargetCommand(program, process.argv.slice(2)); + printJson(commandToJson(target, { includeIdentity: target === program })); + } + process.exit(0); + } + if (err.code === 'commander.version') { process.exit(0); } if (isJsonMode()) { diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 89dd257..75d4814 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -5,6 +5,8 @@ import { findCatalogEntry, suggestedActions, getEffectiveCatalog, + deriveSafetyTier, + getCommandSafetyReason, type DeviceCatalogEntry, type CommandSpec, } from '../devices/catalog.js'; @@ -323,10 +325,11 @@ export function isDestructiveCommand( const match = findCatalogEntry(deviceType); if (!match || Array.isArray(match)) return false; const spec = match.commands.find((c) => c.command === cmd); - return Boolean(spec?.destructive); + if (!spec) return false; + return deriveSafetyTier(spec, match) === 'destructive'; } -/** Return the destructiveReason for a command, or null if not destructive / not found. */ +/** Return the safetyReason for a command, or null if not destructive / not found. */ export function getDestructiveReason( deviceType: string | undefined, cmd: string, @@ -337,7 +340,7 @@ export function getDestructiveReason( const match = findCatalogEntry(deviceType); if (!match || Array.isArray(match)) return null; const spec = match.commands.find((c) => c.command === cmd); - return spec?.destructiveReason ?? null; + return spec ? getCommandSafetyReason(spec) : null; } /** @@ -382,7 +385,16 @@ export async function describeDevice( ? { role: catalogEntry.role ?? null, readOnly: catalogEntry.readOnly ?? false, - commands: catalogEntry.commands, + commands: catalogEntry.commands.map((c) => { + const tier = deriveSafetyTier(c, catalogEntry); + const reason = getCommandSafetyReason(c); + return { + ...c, + safetyTier: tier, + destructive: tier === 'destructive', + ...(reason ? { safetyReason: reason } : {}), + }; + }), statusFields: catalogEntry.statusFields ?? [], ...(liveStatus !== undefined ? { liveStatus } : {}), } diff --git a/src/mcp/device-history.ts b/src/mcp/device-history.ts index ca94350..2b8df3e 100644 --- a/src/mcp/device-history.ts +++ b/src/mcp/device-history.ts @@ -10,9 +10,17 @@ export interface HistoryEntry { } export interface ControlEvent { + // Legacy type prefix (kept as of v2.7; removed in v3.0). type: '__connect' | '__reconnect' | '__disconnect' | '__heartbeat'; at: string; eventId: string; + // P6 unified-envelope additive fields — present on records written by + // `events mqtt-tail` in v2.7+. Optional so older entries still parse. + schemaVersion?: string; + source?: 'mqtt'; + kind?: 'control'; + controlKind?: 'connect' | 'reconnect' | 'disconnect' | 'heartbeat'; + t?: string; } export interface DeviceHistory { diff --git a/src/schema/field-aliases.ts b/src/schema/field-aliases.ts index b6327e0..a79bc33 100644 --- a/src/schema/field-aliases.ts +++ b/src/schema/field-aliases.ts @@ -1,6 +1,21 @@ import { UsageError } from '../utils/output.js'; +/** + * User-facing aliases for canonical field names. + * + * Keys are canonical names (matching API response keys and CLI/schema output); + * values are lowercase alternatives a user may type for `--fields` or `--filter`. + * + * Conflict rules (do not add an alias that violates these — tests will fail): + * - `temp` is exclusive to `temperature` (NOT `colorTemperature`, `targetTemperature`). + * - `motion` is exclusive to `moveDetected`; `moving` uses `active` instead. + * - `mode` is exclusive to top-level `mode` (preset); device-specific modes go through `deviceMode`. + * - Reserved / too-generic words never appear as aliases: `auto`, `status`, `state`, + * `switch`, `type`, `on`, `off`. + * - Device-type words are never aliases: `lock`, `fan`. + */ export const FIELD_ALIASES: Record = { + // Identification (shared with list/filter) deviceId: ['id'], deviceName: ['name'], deviceType: ['type'], @@ -11,8 +26,76 @@ export const FIELD_ALIASES: Record = { hubDeviceId: ['hub'], enableCloudService: ['cloud'], alias: ['alias'], + + // Phase 1 — common status fields + battery: ['batt', 'bat'], + temperature: ['temp', 'ambient'], + colorTemperature: ['kelvin', 'colortemp'], + humidity: ['humid', 'rh'], + brightness: ['bright', 'bri'], + fanSpeed: ['speed'], + position: ['pos'], + moveDetected: ['motion'], + openState: ['open'], + doorState: ['door'], + CO2: ['co2'], + power: ['enabled'], + mode: ['preset'], + + // Phase 2 — niche device fields + childLock: ['safe', 'childlock'], + targetTemperature: ['setpoint', 'target'], + electricCurrent: ['current', 'amps'], + voltage: ['volts'], + usedElectricity: ['energy', 'kwh'], + electricityOfDay: ['daily', 'today'], + weight: ['load'], + version: ['firmware', 'fw'], + lightLevel: ['light', 'lux'], + oscillation: ['swing', 'osc'], + verticalOscillation: ['vswing'], + nightStatus: ['night'], + chargingStatus: ['charging', 'charge'], + switch1Status: ['ch1', 'channel1'], + switch2Status: ['ch2', 'channel2'], + taskType: ['task'], + moving: ['active'], + onlineStatus: ['online_status'], + workingStatus: ['working'], + + // Phase 3 — catalog statusFields coverage + group: ['cluster'], + calibrate: ['calibration', 'calib'], + direction: ['tilt'], + deviceMode: ['devmode'], + nebulizationEfficiency: ['mist', 'spray'], + sound: ['audio'], + lackWater: ['tank', 'water-low'], + filterElement: ['filter'], + color: ['rgb', 'hex'], + useTime: ['runtime', 'uptime'], + switchStatus: ['relay'], + lockState: ['locked'], + slidePosition: ['slide'], + + // Phase 4 — ultra-niche sensor + webhook fields (~98% coverage target) + waterLeakDetect: ['leak', 'water'], + pressure: ['press', 'pa'], + moveCount: ['movecnt'], + errorCode: ['err'], + buttonName: ['btn', 'button'], + pressedAt: ['pressed'], + deviceMac: ['mac'], + detectionState: ['detected', 'detect'], }; +/** + * Resolve a user-typed field name to its canonical form against an allowed list. + * + * Matching is case-insensitive and trims surrounding whitespace. Direct matches + * win over alias matches. Throws UsageError if the input is empty or does not + * match any canonical / alias in the allowed list. + */ export function resolveField( input: string, allowedCanonical: readonly string[], @@ -24,6 +107,8 @@ export function resolveField( for (const canonical of allowedCanonical) { if (canonical.toLowerCase() === normalized) return canonical; + } + for (const canonical of allowedCanonical) { const aliases = FIELD_ALIASES[canonical] ?? []; if (aliases.some((a) => a.toLowerCase() === normalized)) return canonical; } @@ -32,6 +117,17 @@ export function resolveField( ); } +/** + * Resolve every field in a list. Preserves order and the original UsageError + * from resolveField() on the first unknown input. + */ +export function resolveFieldList( + inputs: readonly string[], + allowedCanonical: readonly string[], +): string[] { + return inputs.map((f) => resolveField(f, allowedCanonical)); +} + export function listSupportedFieldInputs( allowedCanonical: readonly string[], ): string[] { @@ -43,3 +139,10 @@ export function listSupportedFieldInputs( return [...out]; } +/** + * All canonical keys known to the alias registry. Use when no dynamic + * canonical list is available (e.g. `watch` before the first poll response). + */ +export function listAllCanonical(): string[] { + return Object.keys(FIELD_ALIASES); +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts new file mode 100644 index 0000000..3e4549b --- /dev/null +++ b/src/utils/help-json.ts @@ -0,0 +1,99 @@ +import type { Command, Option, Argument } from 'commander'; +import { IDENTITY } from '../commands/identity.js'; + +interface ArgJson { + name: string; + required: boolean; + variadic: boolean; + description: string; +} + +interface OptionJson { + flags: string; + description: string; + defaultValue?: unknown; + choices?: string[]; +} + +interface SubcommandJson { + name: string; + description: string; +} + +export interface CommandJson { + name: string; + description: string; + /** Root-only — present only when commandToJson() is called with {includeIdentity:true}. */ + product?: string; + domain?: string; + vendor?: string; + apiVersion?: string; + apiDocs?: string; + productCategories?: readonly string[]; + arguments: ArgJson[]; + options: OptionJson[]; + subcommands: SubcommandJson[]; +} + +export interface CommandToJsonOptions { + /** Inject product identity fields at top level. Intended for the root program only. */ + includeIdentity?: boolean; +} + +export function commandToJson(cmd: Command, opts: CommandToJsonOptions = {}): CommandJson { + const args: ArgJson[] = (cmd.registeredArguments as Argument[]).map((a) => ({ + name: a.name(), + required: a.required, + variadic: a.variadic, + description: a.description ?? '', + })); + + const options: OptionJson[] = (cmd.options as Option[]) + .filter((o) => o.long !== '--help' && o.long !== '--version') + .map((o) => { + const entry: OptionJson = { flags: o.flags, description: o.description ?? '' }; + if (o.defaultValue !== undefined) entry.defaultValue = o.defaultValue; + if (o.argChoices && o.argChoices.length > 0) entry.choices = o.argChoices; + return entry; + }); + + const subcommands: SubcommandJson[] = cmd.commands + .filter((c) => !c.name().startsWith('_')) + .map((c) => ({ name: c.name(), description: c.description() })); + + const out: CommandJson = { + name: cmd.name(), + description: cmd.description(), + arguments: args, + options, + subcommands, + }; + + if (opts.includeIdentity) { + out.product = IDENTITY.product; + out.domain = IDENTITY.domain; + out.vendor = IDENTITY.vendor; + out.apiVersion = IDENTITY.apiVersion; + out.apiDocs = IDENTITY.apiDocs; + out.productCategories = IDENTITY.productCategories; + } + + return out; +} + +/** Walk argv tokens (skipping flags) to find the deepest matching subcommand. */ +export function resolveTargetCommand(root: Command, argv: string[]): Command { + let cmd = root; + for (const token of argv) { + if (token.startsWith('-')) continue; + const sub = cmd.commands.find( + (c) => c.name() === token || (c.aliases() as string[]).includes(token) + ); + if (sub) { + cmd = sub; + } else { + break; + } + } + return cmd; +} diff --git a/src/utils/output.ts b/src/utils/output.ts index 4e523f3..a3b6f13 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -34,6 +34,29 @@ export function emitJsonError(errorPayload: Record): void { } } +/** + * P7: emit the stream-header first line for any NDJSON/streaming command + * running under `--json`. Downstream JSON consumers can key on + * `{ stream: true }` to distinguish the header from subsequent event + * lines, and on `eventKind` / `cadence` to pick a parser strategy. + * + * Non-streaming commands (single-object / array output) do NOT emit this + * header — only watch / events tail / events mqtt-tail. + */ +export function emitStreamHeader(opts: { + eventKind: 'tick' | 'event'; + cadence: 'poll' | 'push'; +}): void { + console.log( + JSON.stringify({ + schemaVersion: '1', + stream: true, + eventKind: opts.eventKind, + cadence: opts.cadence, + }), + ); +} + interface ExitWithErrorOptions { message: string; kind?: 'usage' | 'guard' | 'runtime'; diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index b69bf3f..91627b7 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -508,36 +508,84 @@ describe('createClient — 429 retry', () => { expect(axiosMock.__instance.request).not.toHaveBeenCalled(); }); - it('records a quota entry on a successful response', () => { + it('records a quota entry on every dispatched request (P8)', () => { process.argv = ['node', 'cli', 'devices', 'list']; createClient(); - const response = { - data: { statusCode: 100, body: {} }, - config: { - method: 'get', - baseURL: 'https://api.switch-bot.com', - url: '/v1.1/devices', - }, + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + headers: {}, }; - captured.success!(response); + // P8: quota is recorded in the request interceptor BEFORE dispatch so + // that timeouts, DNS errors, 5xx responses, and aborted calls also + // count against the daily cap. + captured.request!(config); expect(quotaMock.recordRequest).toHaveBeenCalledWith( 'GET', 'https://api.switch-bot.com/v1.1/devices' ); }); - it('--no-quota skips quota recording', () => { + it('records quota even when the request ultimately 5xxs (P8)', () => { + process.argv = ['node', 'cli', 'devices', 'list']; + createClient(); + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + headers: {}, + }; + captured.request!(config); + expect(quotaMock.recordRequest).toHaveBeenCalledTimes(1); + // The response interceptor no longer records — all bookkeeping is in + // the request interceptor, so subsequent failure handling must not + // bump the counter again. + try { + captured.failure!({ + response: { status: 500, headers: {} }, + config, + message: 'server error', + }); + } catch { + /* ApiError expected */ + } + expect(quotaMock.recordRequest).toHaveBeenCalledTimes(1); + }); + + it('records quota even when the request times out (P8)', () => { + process.argv = ['node', 'cli', 'devices', 'list', '--retry-on-5xx', '0']; + createClient(); + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + headers: {}, + }; + captured.request!(config); + expect(quotaMock.recordRequest).toHaveBeenCalledTimes(1); + try { + captured.failure!({ + code: 'ETIMEDOUT', + config, + message: 'timed out', + }); + } catch { + /* ApiError expected */ + } + expect(quotaMock.recordRequest).toHaveBeenCalledTimes(1); + }); + + it('--no-quota skips quota recording (P8)', () => { process.argv = ['node', 'cli', 'devices', 'list', '--no-quota']; createClient(); - const response = { - data: { statusCode: 100, body: {} }, - config: { - method: 'get', - baseURL: 'https://api.switch-bot.com', - url: '/v1.1/devices', - }, + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + headers: {}, }; - captured.success!(response); + captured.request!(config); expect(quotaMock.recordRequest).not.toHaveBeenCalled(); }); }); diff --git a/tests/commands/agent-bootstrap.test.ts b/tests/commands/agent-bootstrap.test.ts index b0cd1f4..50f291b 100644 --- a/tests/commands/agent-bootstrap.test.ts +++ b/tests/commands/agent-bootstrap.test.ts @@ -64,6 +64,11 @@ describe('agent-bootstrap', () => { expect(payload.schemaVersion).toBeDefined(); const data = payload.data as Record; expect(data.identity).toBeDefined(); + const identity = data.identity as Record; + expect(identity.product).toBe('SwitchBot'); + // v2.7.1: agent-bootstrap shares the canonical IDENTITY — carries apiDocs + productCategories. + expect(identity.apiDocs).toMatch(/OpenWonderLabs/); + expect(Array.isArray(identity.productCategories)).toBe(true); expect(data.safetyTiers).toBeDefined(); expect(data.quickReference).toBeDefined(); expect(Array.isArray(data.nameStrategies)).toBe(true); diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 4e40697..75997a3 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -405,6 +405,70 @@ describe('devices batch', () => { expect(parsed.data.plan.steps.map((s: { deviceId: string }) => s.deviceId).sort()).toEqual(['BOT1', 'BOT2']); }); + it('P12: --dry-run --emit-plan emits the same plan doc as the legacy --plan', async () => { + flagsMock.dryRun = true; + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--filter', + 'type=Bot', + '--emit-plan', + ]); + + expect(result.exitCode).toBeNull(); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.data.plan.command).toBe('turnOn'); + expect(parsed.data.plan.stepCount).toBe(2); + // --emit-plan must not trigger the deprecation warning. + expect(result.stderr.join('\n')).not.toMatch(/deprecated/i); + }); + + it('P12: legacy --plan still works but emits a deprecation warning on stderr', async () => { + flagsMock.dryRun = true; + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--filter', + 'type=Bot', + '--plan', + ]); + + expect(result.exitCode).toBeNull(); + // Plan JSON still emitted on stdout (contract unchanged). + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.data.plan.command).toBe('turnOn'); + // Deprecation warning lands on stderr, not stdout. + expect(result.stderr.join('\n')).toMatch(/--plan is deprecated/); + expect(result.stderr.join('\n')).toMatch(/--emit-plan/); + }); + + it('P12: supplying both --plan and --emit-plan is a usage error', async () => { + flagsMock.dryRun = true; + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + + const result = await runCli(registerDevicesCommand, [ + 'devices', + 'batch', + 'turnOn', + '--filter', + 'type=Bot', + '--plan', + '--emit-plan', + ]); + + expect(result.exitCode).toBe(2); + expect(result.stderr.join('\n')).toMatch(/--plan is deprecated.*cannot be combined|--emit-plan/i); + }); + it('--idempotency-key alias sets the same prefix as --idempotency-key-prefix', async () => { flagsMock.dryRun = true; apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index e0012d5..272aeff 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -125,6 +125,13 @@ describe('capabilities', () => { expect(cat.typeCount as number).toBeGreaterThan(10); }); + it('P11: catalog.safetyTiersInUse includes "read" and catalog.readOnlyQueryCount > 0', async () => { + const out = await runCapabilities(); + const cat = out.catalog as Record; + expect((cat.safetyTiersInUse as string[])).toContain('read'); + expect((cat.readOnlyQueryCount as number)).toBeGreaterThan(0); + }); + it('surfaces.mcp.tools includes send_command, account_overview, get_device_history and query_device_history', async () => { const out = await runCapabilities(); const mcp = (out.surfaces as Record).mcp; @@ -239,4 +246,18 @@ describe('capabilities B3/B4', () => { expect(metaSet!.agentSafetyTier).toBe('action'); expect(metaSet!.mutating).toBe(true); }); + + it('P15: resources catalog exposes scenes / webhooks / keys', async () => { + const out = await runCapabilitiesWith([]); + const resources = out.resources as Record; + expect(resources).toBeDefined(); + expect(resources.scenes).toBeDefined(); + expect(resources.webhooks).toBeDefined(); + expect(Array.isArray(resources.keys)).toBe(true); + const webhooks = resources.webhooks as { events: Array<{ eventType: string }>; endpoints: Array<{ verb: string }> }; + expect(webhooks.events.length).toBeGreaterThanOrEqual(10); + expect(webhooks.endpoints.map((e) => e.verb).sort()).toEqual(['delete', 'query', 'setup', 'update']); + const keys = resources.keys as Array<{ keyType: string }>; + expect(keys.map((k) => k.keyType).sort()).toEqual(['disposable', 'permanent', 'timeLimit', 'urgent']); + }); }); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 40b5928..d6a6c49 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -542,6 +542,38 @@ describe('devices command', () => { expect(out.data.deviceList).toHaveLength(2); expect(out.data.deviceList.map((d: { deviceId: string }) => d.deviceId)).not.toContain('BLE-001'); }); + + it('--filter family=Home filters by familyName', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'family=Home', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(3); + }); + + it('--filter hub=HUB-1 filters by hubDeviceId', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'hub=HUB-1', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(1); + expect(out.data.deviceList[0].deviceId).toBe('ABC123'); + }); + + it('--filter cloud=true filters by enableCloudService', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'cloud=true', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + // ABC123 (true) and NOHUB-1 (true); BLE-001 (false) excluded + expect(out.data.deviceList).toHaveLength(2); + expect(out.data.deviceList.map((d: { deviceId: string }) => d.deviceId)).not.toContain('BLE-001'); + }); + + it('--filter roomID=R-LIVING filters by roomID', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--filter', 'roomID=R-LIVING', '--json']); + const out = JSON.parse(res.stdout.join('\n')); + expect(out.data.deviceList).toHaveLength(2); + expect(out.data.deviceList.map((d: { deviceId: string }) => d.deviceId)).not.toContain('BLE-001'); + }); }); // ===================================================================== @@ -667,6 +699,91 @@ describe('devices command', () => { // null maps to empty string in cellToString; _fetchedAt column is also present expect(lines[1]).toMatch(/^on\t\t/); }); + + // P1 — FIELD_ALIASES dispatch on --fields + describe('--fields alias resolution (P1)', () => { + it('resolves "batt" → battery, "humid" → humidity (tsv)', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: 87, humidity: 42, temperature: 22 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', 'batt,humid', + ]); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('battery\thumidity'); + expect(lines[1]).toBe('87\t42'); + }); + + it('resolves "temp" → temperature (not colorTemperature) even when both are present', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { temperature: 24, colorTemperature: 4000, battery: 50 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', 'temp', + ]); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('temperature'); + expect(lines[1]).toBe('24'); + }); + + it('resolves "kelvin" → colorTemperature', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { temperature: 24, colorTemperature: 4000 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', 'kelvin', + ]); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('colorTemperature'); + expect(lines[1]).toBe('4000'); + }); + + it('is case-insensitive (BATT, Battery, BaTt all resolve the same way)', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { battery: 77 } }, + }); + for (const f of ['BATT', 'Battery', 'BaTt']) { + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: { battery: 77 } } }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', f, + ]); + expect(res.stdout.join('\n').split('\n')[0]).toBe('battery'); + } + }); + + it('passes canonical names through unchanged', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: 90 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', 'power,battery', + ]); + expect(res.stdout.join('\n').split('\n')[0]).toBe('power\tbattery'); + }); + + it('exits 2 with candidate list on unknown field', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: 80 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', 'zombie', + ]); + expect(res.exitCode).toBe(2); + const err = res.stderr.join('\n'); + expect(err).toMatch(/zombie/); + expect(err).toMatch(/Supported|power|battery/i); + }); + + it('preserves user input order in output', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: 80, humidity: 40 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'D1', '--format', 'tsv', '--fields', 'humid,power,batt', + ]); + expect(res.stdout.join('\n').split('\n')[0]).toBe('humidity\tpower\tbattery'); + }); + }); }); // ===================================================================== @@ -2276,4 +2393,55 @@ describe('devices command', () => { expect(out).toContain(DRY_ID); }); }); + + // ===================================================================== + // --help --json + // ===================================================================== + describe('--help --json', () => { + it('devices list --help --json returns structured JSON', async () => { + 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.data.name).toBe('list'); + expect(Array.isArray(parsed.data.options)).toBe(true); + expect(Array.isArray(parsed.data.arguments)).toBe(true); + expect(parsed.data.options.some((o: { flags: string }) => o.flags.includes('--filter'))).toBe(true); + }); + + it('devices command --help --json includes arguments', async () => { + const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'command', '--help']); + expect(res.exitCode).toBe(0); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.data.name).toBe('command'); + expect(parsed.data.arguments.length).toBeGreaterThan(0); + }); + }); + + // ===================================================================== + // destructive normalization + // ===================================================================== + describe('devices commands --json destructive normalization', () => { + it('every command in Bot catalog has explicit destructive boolean', async () => { + const res = await runCli(registerDevicesCommand, ['--json', 'devices', 'commands', 'Bot']); + expect(res.exitCode).toBeNull(); + const parsed = JSON.parse(res.stdout.join('\n')); + const cmds: Array<{ destructive?: boolean }> = parsed.data.commands; + expect(cmds.length).toBeGreaterThan(0); + for (const c of cmds) { + expect(typeof c.destructive).toBe('boolean'); + } + }); + + it('Smart Lock unlock has destructive:true, lock has destructive:false', async () => { + 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; destructive: boolean }> = parsed.data.commands; + const unlock = cmds.find((c) => c.command === 'unlock'); + const lock = cmds.find((c) => c.command === 'lock'); + expect(unlock?.destructive).toBe(true); + expect(lock?.destructive).toBe(false); + }); + }); }); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 9759ded..3243f21 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -162,4 +162,222 @@ describe('doctor command', () => { fetchSpy.mockRestore(); } }); + + // --------------------------------------------------------------------- + // P9: quota headroom + catalog-schema + audit checks + // --------------------------------------------------------------------- + it('P9: quota check exposes percentUsed / remaining / projectedResetTime when the quota file exists', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const sbDir = path.join(tmp, '.switchbot'); + fs.mkdirSync(sbDir, { recursive: true }); + // 100 requests today — well under 80%, so status must stay 'ok'. + const today = new Date(); + const y = today.getFullYear(); + const m = String(today.getMonth() + 1).padStart(2, '0'); + const d = String(today.getDate()).padStart(2, '0'); + const date = `${y}-${m}-${d}`; + fs.writeFileSync( + path.join(sbDir, 'quota.json'), + JSON.stringify({ days: { [date]: { total: 100, endpoints: {} } } }), + ); + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const q = payload.data.checks.find((c: { name: string }) => c.name === 'quota'); + expect(q.status).toBe('ok'); + expect(q.detail.percentUsed).toBe(1); + expect(q.detail.remaining).toBe(9_900); + expect(q.detail.total).toBe(100); + expect(q.detail.dailyCap).toBe(10_000); + expect(typeof q.detail.projectedResetTime).toBe('string'); + expect(q.detail.projectedResetTime).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(typeof q.detail.recommendation).toBe('string'); + }); + + it('P9: quota check warns when usage is over 80%', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const sbDir = path.join(tmp, '.switchbot'); + fs.mkdirSync(sbDir, { recursive: true }); + const today = new Date(); + const y = today.getFullYear(); + const m = String(today.getMonth() + 1).padStart(2, '0'); + const d = String(today.getDate()).padStart(2, '0'); + fs.writeFileSync( + path.join(sbDir, 'quota.json'), + JSON.stringify({ days: { [`${y}-${m}-${d}`]: { total: 9_500, endpoints: {} } } }), + ); + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const q = payload.data.checks.find((c: { name: string }) => c.name === 'quota'); + expect(q.status).toBe('warn'); + expect(q.detail.percentUsed).toBe(95); + expect(q.detail.recommendation).toMatch(/90|reset/); + }); + + it('P9: catalog-schema check passes when bootstrap and catalog versions match', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const cs = payload.data.checks.find((c: { name: string }) => c.name === 'catalog-schema'); + expect(cs).toBeDefined(); + expect(cs.status).toBe('ok'); + expect(cs.detail.match).toBe(true); + expect(cs.detail.catalogSchemaVersion).toBe(cs.detail.bootstrapExpectsVersion); + }); + + it('P9: audit check reports "not present" when the audit log file is missing', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const audit = payload.data.checks.find((c: { name: string }) => c.name === 'audit'); + expect(audit).toBeDefined(); + expect(audit.status).toBe('ok'); + expect(audit.detail.enabled).toBe(false); + }); + + it('P9: audit check warns and lists recent errors when the audit log has failures in the last 24h', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const sbDir = path.join(tmp, '.switchbot'); + fs.mkdirSync(sbDir, { recursive: true }); + const recent = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); + const lines = [ + JSON.stringify({ auditVersion: 1, t: recent, kind: 'command', deviceId: 'BOT1', command: 'turnOff', result: 'error', error: 'rate limit' }), + JSON.stringify({ auditVersion: 1, t: stale, kind: 'command', deviceId: 'BOT1', command: 'turnOff', result: 'error', error: 'old' }), + JSON.stringify({ auditVersion: 1, t: recent, kind: 'command', deviceId: 'BOT2', command: 'press', result: 'ok' }), + ]; + fs.writeFileSync(path.join(sbDir, 'audit.log'), lines.join('\n') + '\n'); + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const audit = payload.data.checks.find((c: { name: string }) => c.name === 'audit'); + expect(audit.status).toBe('warn'); + expect(audit.detail.enabled).toBe(true); + expect(audit.detail.totalErrors).toBe(2); + expect(audit.detail.errorsLast24h).toBe(1); + expect(audit.detail.recent).toHaveLength(1); + expect(audit.detail.recent[0].command).toBe('turnOff'); + expect(audit.detail.recent[0].error).toBe('rate limit'); + }); + + // --------------------------------------------------------------------- + // P10: MCP dry-run + --section / --list / --fix / --probe + // --------------------------------------------------------------------- + it('P10: mcp check is ok and reports a toolCount when the server instantiates', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const mcp = payload.data.checks.find((c: { name: string }) => c.name === 'mcp'); + expect(mcp).toBeDefined(); + expect(mcp.status).toBe('ok'); + expect(mcp.detail.serverInstantiated).toBe(true); + expect(typeof mcp.detail.toolCount).toBe('number'); + expect(mcp.detail.toolCount).toBeGreaterThan(0); + expect(Array.isArray(mcp.detail.tools)).toBe(true); + expect(mcp.detail.transportsAvailable).toEqual(['stdio', 'http']); + }); + + it('P10: --list prints the registered check names without running any check', async () => { + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--list']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(payload.data.checks).toBeDefined(); + const names = payload.data.checks.map((c: { name: string }) => c.name); + expect(names).toContain('credentials'); + expect(names).toContain('mcp'); + expect(names).toContain('catalog-schema'); + expect(names).toContain('audit'); + // Should NOT include check results — just registry entries with description. + expect(payload.data.summary).toBeUndefined(); + expect(payload.data.overall).toBeUndefined(); + for (const entry of payload.data.checks) { + expect(typeof entry.description).toBe('string'); + expect(entry.status).toBeUndefined(); + } + }); + + it('P10: --section runs only the named checks (sorted by registry order)', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'credentials,mcp']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const names = payload.data.checks.map((c: { name: string }) => c.name); + expect(names).toEqual(['credentials', 'mcp']); + expect(payload.data.summary.ok + payload.data.summary.warn + payload.data.summary.fail).toBe(2); + }); + + it('P10: --section dedupes duplicate names', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--section', 'mcp,mcp,credentials,mcp']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const names = payload.data.checks.map((c: { name: string }) => c.name); + expect(names).toEqual(['credentials', 'mcp']); + }); + + it('P10: --section rejects unknown check names with exit 2 + valid-names hint', async () => { + 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.error.message).toMatch(/bogus/); + expect(payload.error.message).toMatch(/Valid:/); + }); + + it('P10: --fix without --yes reports cache-clear as not-applied (pass --yes to apply)', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + // With no stored cache, the cache check status is still 'ok', so --fix + // should not queue any actions. Force a non-ok cache check by creating + // a list cache file that describeCache() can see, then scenarios where + // we expect fixes to be listed (or empty) both verify the fixes field. + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--fix']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(Array.isArray(payload.data.fixes)).toBe(true); + }); + + it('P10: --fix --yes applies safe fixes and records them in the fixes array', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--fix', '--yes']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(Array.isArray(payload.data.fixes)).toBe(true); + // Every fix entry must have check/action/applied fields. + for (const f of payload.data.fixes) { + expect(typeof f.check).toBe('string'); + expect(typeof f.action).toBe('string'); + expect(typeof f.applied).toBe('boolean'); + } + }); + + it('P10: --probe runs the MQTT live-probe variant and tolerates failure as warn', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + // Stub fetch so fetchMqttCredential rejects; the probe should catch + // and surface probe:'failed' with status 'warn' (never hang the CLI). + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); + try { + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--probe', '--section', 'mqtt']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const mqtt = payload.data.checks.find((c: { name: string }) => c.name === 'mqtt'); + expect(mqtt.status).toBe('warn'); + expect(mqtt.detail.probe).toBe('failed'); + expect(typeof mqtt.detail.reason).toBe('string'); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('P10: --probe without credentials reports probe:skipped', async () => { + delete process.env.SWITCHBOT_TOKEN; + delete process.env.SWITCHBOT_SECRET; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor', '--probe', '--section', 'mqtt']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const mqtt = payload.data.checks.find((c: { name: string }) => c.name === 'mqtt'); + expect(mqtt.detail.probe).toBe('skipped'); + }); }); diff --git a/tests/commands/error-envelope.test.ts b/tests/commands/error-envelope.test.ts new file mode 100644 index 0000000..8542c08 --- /dev/null +++ b/tests/commands/error-envelope.test.ts @@ -0,0 +1,188 @@ +/** + * P5: error-envelope contract test. + * + * Every CLI error path that goes through `exitWithError(...)` MUST emit on + * stdout (not stderr) a JSON object of shape + * { schemaVersion: "1.1", error: { code, kind, message, ... } } + * when running under `--json`. + * + * This test drives typical error paths across several commands and asserts + * the envelope is well-formed JSON with the required fields. It is a + * regression guard against the old `console.error(JSON.stringify(...))` + * bypass pattern that some commands used before P5. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; + +import { + emitJsonError, + exitWithError, + SCHEMA_VERSION, +} from '../../src/utils/output.js'; + +describe('error envelope contract (P5)', () => { + let stdoutSpy: ReturnType; + let stderrSpy: ReturnType; + let exitSpy: ReturnType; + + beforeEach(() => { + stdoutSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stderrSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(((_code?: number) => { + throw new Error('process.exit'); + }) as never); + }); + + it('emitJsonError wraps payload in { schemaVersion, error }', () => { + emitJsonError({ code: 2, kind: 'usage', message: 'bad arg' }); + + const emitted = stdoutSpy.mock.calls[0]?.[0]; + expect(typeof emitted).toBe('string'); + const parsed = JSON.parse(emitted as string); + expect(parsed).toEqual({ + schemaVersion: SCHEMA_VERSION, + error: { code: 2, kind: 'usage', message: 'bad arg' }, + }); + }); + + it('exitWithError in --json mode emits envelope on stdout and exits with the code', () => { + const prevArgv = process.argv; + process.argv = [...prevArgv, '--json']; + try { + expect(() => + exitWithError({ + code: 2, + kind: 'usage', + message: 'missing --foo', + hint: 'pass --foo ', + context: { flag: '--foo' }, + }), + ).toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(2); + const emitted = stdoutSpy.mock.calls[0]?.[0]; + const parsed = JSON.parse(emitted as string); + expect(parsed.schemaVersion).toBe(SCHEMA_VERSION); + expect(parsed.error).toMatchObject({ + code: 2, + kind: 'usage', + message: 'missing --foo', + hint: 'pass --foo ', + context: { flag: '--foo' }, + }); + } finally { + process.argv = prevArgv; + } + }); + + it('exitWithError in plain mode writes message+hint to stderr, not stdout', () => { + expect(() => + exitWithError({ + code: 2, + kind: 'usage', + message: 'boom', + hint: 'try --help', + }), + ).toThrow('process.exit'); + + expect(exitSpy).toHaveBeenCalledWith(2); + expect(stdoutSpy).not.toHaveBeenCalled(); + const stderrLines = stderrSpy.mock.calls.map((c) => c[0]); + expect(stderrLines).toContain('boom'); + expect(stderrLines).toContain('try --help'); + }); + + it('exitWithError kind defaults to "usage" and code defaults to 2', () => { + const prevArgv = process.argv; + process.argv = [...prevArgv, '--json']; + try { + expect(() => exitWithError('minimum usage error')).toThrow('process.exit'); + + const parsed = JSON.parse(stdoutSpy.mock.calls[0]?.[0] as string); + expect(parsed.error.code).toBe(2); + expect(parsed.error.kind).toBe('usage'); + expect(parsed.error.message).toBe('minimum usage error'); + expect(exitSpy).toHaveBeenCalledWith(2); + } finally { + process.argv = prevArgv; + } + }); + + it('exitWithError supports runtime kind + non-2 exit codes', () => { + const prevArgv = process.argv; + process.argv = [...prevArgv, '--json']; + try { + expect(() => + exitWithError({ + code: 1, + kind: 'runtime', + message: 'subprocess failed', + }), + ).toThrow('process.exit'); + + const parsed = JSON.parse(stdoutSpy.mock.calls[0]?.[0] as string); + expect(parsed.error.code).toBe(1); + expect(parsed.error.kind).toBe('runtime'); + expect(exitSpy).toHaveBeenCalledWith(1); + } finally { + process.argv = prevArgv; + } + }); + + it('exitWithError "extra" fields are merged into error payload (flat)', () => { + const prevArgv = process.argv; + process.argv = [...prevArgv, '--json']; + try { + expect(() => + exitWithError({ + code: 2, + message: 'validation failed', + extra: { validationKind: 'unknown-command', deviceId: 'D-1' }, + }), + ).toThrow('process.exit'); + + const parsed = JSON.parse(stdoutSpy.mock.calls[0]?.[0] as string); + expect(parsed.error.validationKind).toBe('unknown-command'); + expect(parsed.error.deviceId).toBe('D-1'); + } finally { + process.argv = prevArgv; + } + }); + + it('no CLI command source file still uses the emitJsonError + process.exit bypass', async () => { + // Sanity: import the command modules and confirm exitWithError is the + // canonical path. This is a cheap textual audit that fails if a future + // contributor re-introduces the bypass. + const fs = await import('node:fs'); + const path = await import('node:path'); + const cmdDir = path.resolve(__dirname, '../../src/commands'); + const files = fs + .readdirSync(cmdDir) + .filter((f) => f.endsWith('.ts')) + .map((f) => path.join(cmdDir, f)); + + const offenders: string[] = []; + for (const file of files) { + const raw = fs.readFileSync(file, 'utf-8'); + // Look for the bypass pattern: emitJsonError(...) in the same block + // as process.exit(N). Ignore mcp.ts (protocol-level signal handlers + // exit without an envelope — that is intentional). + if (file.endsWith('mcp.ts')) continue; + const hasEmit = /emitJsonError\s*\(/.test(raw); + const hasExit = /process\.exit\s*\(\s*[12]\s*\)/.test(raw); + if (hasEmit && hasExit) offenders.push(path.basename(file)); + } + expect( + offenders, + `command files still pair emitJsonError() with process.exit():\n ${offenders.join('\n ')}`, + ).toEqual([]); + }); +}); + +/** + * Silence unused-vars — keep Command import available for future command-level + * smoke tests under this suite. + */ +void Command; diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts index cc29816..418350f 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -242,6 +242,64 @@ describe('events tail receiver', () => { await new Promise((r) => server.close(() => r())); expect(status).toBe(413); }); + + it('P6: unified envelope carries schemaVersion / source / kind / payload / topic on webhook events', async () => { + const port = await pickPort(); + const received: unknown[] = []; + const server = startReceiver(port, '/', null, (ev) => received.push(ev)); + await postJson(port, '/', { + eventType: 'state-change', + context: { deviceMac: 'BOT-7', deviceType: 'Bot', eventId: 'evt-1' }, + }); + await new Promise((r) => server.close(() => r())); + const ev = received[0] as { + schemaVersion: string; + source: string; + kind: string; + topic: string; + payload: unknown; + eventId: string | null; + deviceId: string | null; + matchedKeys: string[]; + // legacy: + body: unknown; + path: string; + matched: boolean; + }; + expect(ev.schemaVersion).toBe('1'); + expect(ev.source).toBe('webhook'); + expect(ev.kind).toBe('event'); + expect(ev.topic).toBe('/'); + expect(ev.eventId).toBe('evt-1'); + expect(ev.deviceId).toBe('BOT-7'); + expect(ev.matchedKeys).toEqual([]); + // legacy mirror still present: + expect(ev.path).toBe('/'); + expect(ev.body).toEqual(ev.payload); + expect(ev.matched).toBe(true); + }); + + it('P6: matchedKeys lists which filter clauses hit on webhook events', async () => { + const port = await pickPort(); + const received: Array<{ matched: boolean; matchedKeys: string[] }> = []; + const filter: FilterClause[] = [ + { key: 'deviceId', op: 'eq', raw: 'BOT1' }, + { key: 'type', op: 'eq', raw: 'Bot' }, + ]; + const server = startReceiver( + port, + '/', + filter, + (ev) => received.push(ev as { matched: boolean; matchedKeys: string[] }), + ); + await postJson(port, '/', { context: { deviceMac: 'BOT1', deviceType: 'Bot' } }); + await postJson(port, '/', { context: { deviceMac: 'BOT2', deviceType: 'Bot' } }); + await new Promise((r) => server.close(() => r())); + expect(received[0].matched).toBe(true); + expect(received[0].matchedKeys).toEqual(['deviceId', 'type']); + expect(received[1].matched).toBe(false); + expect(received[1].matchedKeys).toEqual([]); + }); }); // --------------------------------------------------------------------------- @@ -303,13 +361,23 @@ describe('events mqtt-tail', () => { expect(res.exitCode).toBe(null); const jsonLines = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l) as { schemaVersion: string; data: { type?: string; topic?: string } }); + .map( + (l) => + JSON.parse(l) as { + stream?: boolean; + schemaVersion?: string; + data?: { type?: string; topic?: string }; + }, + ); + // P7: skip the stream header; __session_start also excluded via its type prefix. const events = jsonLines.filter( - (j) => typeof j.data?.type !== 'string' || !j.data.type.startsWith('__'), + (j) => + j.stream !== true && + (typeof j.data?.type !== 'string' || !j.data.type.startsWith('__')), ); expect(events).toHaveLength(1); expect(events[0].schemaVersion).toBe('1.1'); - expect(events[0].data.topic).toBe('test/topic'); + expect(events[0].data!.topic).toBe('test/topic'); }); it('exits 2 when --max is not a positive integer', async () => { @@ -365,15 +433,101 @@ describe('events mqtt-tail', () => { const res = await runCli(registerEventsCommand, ['--json', 'events', 'mqtt-tail', '--max', '1']); const jsonLines = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l) as { data: { type?: string; state?: string; at?: string; eventId?: string } }); + .map( + (l) => + JSON.parse(l) as { + stream?: boolean; + eventKind?: string; + cadence?: string; + data?: { type?: string; state?: string; at?: string; eventId?: string }; + }, + ); const sessionStart = jsonLines.find((j) => j.data?.type === '__session_start'); expect(sessionStart).toBeDefined(); - expect(sessionStart!.data.state).toBe('connecting'); - expect(typeof sessionStart!.data.at).toBe('string'); - expect(typeof sessionStart!.data.eventId).toBe('string'); - // Must be the FIRST JSON line emitted so consumers see it even if broker - // never connects. - expect((jsonLines[0] as { data: { type?: string } }).data.type).toBe('__session_start'); + expect(sessionStart!.data!.state).toBe('connecting'); + expect(typeof sessionStart!.data!.at).toBe('string'); + expect(typeof sessionStart!.data!.eventId).toBe('string'); + // P7: the very first JSON line under --json is the stream header; + // __session_start is now the second line but still precedes any + // broker activity so consumers still learn we're "connecting". + expect(jsonLines[0].stream).toBe(true); + expect(jsonLines[0].eventKind).toBe('event'); + expect(jsonLines[0].cadence).toBe('push'); + expect(jsonLines[1].data?.type).toBe('__session_start'); + }); + + it('P6: mqtt event record carries unified envelope (source/kind/schemaVersion/deviceId)', async () => { + mqttMock.connectShouldFireMessage = true; + + const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail', '--max', '1']); + expect(res.exitCode).toBe(null); + const jsonLines = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map( + (l) => + JSON.parse(l) as { + type?: string; + source?: string; + kind?: string; + schemaVersion?: string; + topic?: string; + payload?: unknown; + deviceId?: string | null; + }, + ); + const event = jsonLines.find((j) => j.kind === 'event'); + expect(event).toBeDefined(); + expect(event!.schemaVersion).toBe('1'); + expect(event!.source).toBe('mqtt'); + expect(event!.kind).toBe('event'); + expect(event!.topic).toBe('test/topic'); + expect(event!.payload).toEqual({ state: 'on' }); + // deviceId is nullable on records without context — present as `null` + expect(event).toHaveProperty('deviceId'); + }); + + it('P6: mqtt control records carry unified envelope alongside legacy type', async () => { + mqttMock.connectShouldFireState = 'failed'; + const res = await runCli(registerEventsCommand, ['events', 'mqtt-tail']); + const jsonLines = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map( + (l) => + JSON.parse(l) as { + type?: string; + kind?: string; + source?: string; + schemaVersion?: string; + controlKind?: string; + at?: string; + t?: string; + }, + ); + const disconnect = jsonLines.find((j) => j.type === '__disconnect'); + expect(disconnect).toBeDefined(); + expect(disconnect!.kind).toBe('control'); + expect(disconnect!.source).toBe('mqtt'); + expect(disconnect!.schemaVersion).toBe('1'); + expect(disconnect!.controlKind).toBe('disconnect'); + // Legacy field `at` mirrors the unified `t`. + expect(disconnect!.at).toBe(disconnect!.t); + }); + + it('P7: mqtt-tail emits a streaming JSON header as the first JSON line under --json', async () => { + mqttMock.connectShouldFireMessage = true; + const res = await runCli(registerEventsCommand, ['--json', 'events', 'mqtt-tail', '--max', '1']); + const firstJson = res.stdout.find((l) => l.trim().startsWith('{')); + expect(firstJson).toBeDefined(); + const header = JSON.parse(firstJson!) as { + schemaVersion: string; + stream: boolean; + eventKind: string; + cadence: string; + }; + expect(header.schemaVersion).toBe('1'); + expect(header.stream).toBe(true); + expect(header.eventKind).toBe('event'); + expect(header.cadence).toBe('push'); }); }); diff --git a/tests/commands/help-json-contract.test.ts b/tests/commands/help-json-contract.test.ts new file mode 100644 index 0000000..e1576fe --- /dev/null +++ b/tests/commands/help-json-contract.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Command } from 'commander'; +import { commandToJson, type CommandJson } from '../../src/utils/help-json.js'; +import { PRODUCT_TAGLINE } from '../../src/commands/identity.js'; +import { registerConfigCommand } from '../../src/commands/config.js'; +import { registerDevicesCommand } from '../../src/commands/devices.js'; +import { registerScenesCommand } from '../../src/commands/scenes.js'; +import { registerWebhookCommand } from '../../src/commands/webhook.js'; +import { registerCompletionCommand } from '../../src/commands/completion.js'; +import { registerMcpCommand } from '../../src/commands/mcp.js'; +import { registerQuotaCommand } from '../../src/commands/quota.js'; +import { registerCatalogCommand } from '../../src/commands/catalog.js'; +import { registerCacheCommand } from '../../src/commands/cache.js'; +import { registerEventsCommand } from '../../src/commands/events.js'; +import { registerDoctorCommand } from '../../src/commands/doctor.js'; +import { registerSchemaCommand } from '../../src/commands/schema.js'; +import { registerHistoryCommand } from '../../src/commands/history.js'; +import { registerPlanCommand } from '../../src/commands/plan.js'; +import { registerCapabilitiesCommand } from '../../src/commands/capabilities.js'; +import { registerAgentBootstrapCommand } from '../../src/commands/agent-bootstrap.js'; + +const TOP_LEVEL_COMMANDS = [ + 'config', + 'devices', + 'scenes', + 'webhook', + 'completion', + 'mcp', + 'quota', + 'catalog', + 'cache', + 'events', + 'doctor', + 'schema', + 'history', + 'plan', + 'capabilities', + 'agent-bootstrap', +] as const; + +function buildProgram(): Command { + const program = new Command(); + program.name('switchbot').description(PRODUCT_TAGLINE).version('0.0.0-test'); + registerConfigCommand(program); + registerDevicesCommand(program); + registerScenesCommand(program); + registerWebhookCommand(program); + registerCompletionCommand(program); + registerMcpCommand(program); + registerQuotaCommand(program); + registerCatalogCommand(program); + registerCacheCommand(program); + registerEventsCommand(program); + registerDoctorCommand(program); + registerSchemaCommand(program); + registerHistoryCommand(program); + registerPlanCommand(program); + registerCapabilitiesCommand(program); + registerAgentBootstrapCommand(program); + return program; +} + +describe('help --json contract coverage', () => { + let program: Command; + + beforeAll(() => { + program = buildProgram(); + }); + + it('all 16 top-level commands are registered', () => { + const names = program.commands.map((c) => c.name()).sort(); + expect(names).toEqual([...TOP_LEVEL_COMMANDS].sort()); + }); + + describe.each(TOP_LEVEL_COMMANDS)('top-level command: %s', (cmdName) => { + let target: Command; + let json: CommandJson; + + beforeAll(() => { + const match = program.commands.find((c) => c.name() === cmdName); + if (!match) throw new Error(`command ${cmdName} not registered`); + target = match; + json = commandToJson(target); + }); + + it('has non-empty name matching registration', () => { + expect(json.name).toBe(cmdName); + expect(json.name.length).toBeGreaterThan(0); + }); + + it('has a non-empty description', () => { + expect(typeof json.description).toBe('string'); + expect(json.description.length).toBeGreaterThan(0); + }); + + it('arguments field is an array (possibly empty)', () => { + expect(Array.isArray(json.arguments)).toBe(true); + for (const a of json.arguments) { + expect(a.name).toBeTypeOf('string'); + expect(a.name.length).toBeGreaterThan(0); + expect(a.required).toBeTypeOf('boolean'); + expect(a.variadic).toBeTypeOf('boolean'); + } + }); + + it('options field is an array; each has flags + description', () => { + expect(Array.isArray(json.options)).toBe(true); + for (const opt of json.options) { + expect(opt.flags).toBeTypeOf('string'); + expect(opt.flags.length).toBeGreaterThan(0); + expect(opt.description).toBeTypeOf('string'); + } + }); + + it('options never include the auto --help or --version entries', () => { + const flagsList = json.options.map((o) => o.flags); + expect(flagsList).not.toContain('-h, --help'); + expect(flagsList).not.toContain('-V, --version'); + }); + + it('subcommands field is an array; each entry has name + description', () => { + expect(Array.isArray(json.subcommands)).toBe(true); + for (const sub of json.subcommands) { + expect(sub.name).toBeTypeOf('string'); + expect(sub.name.length).toBeGreaterThan(0); + expect(sub.description).toBeTypeOf('string'); + // Subcommand descriptions should not be empty, but some commander + // defaults can land without one — don't force it, just surface a + // failure when someone forgets. + expect(sub.description.length).toBeGreaterThan(0); + } + }); + }); + + describe('subcommand recursion: commands with subcommands expose each sub', () => { + it('devices subtree has the expected core verbs', () => { + const devices = program.commands.find((c) => c.name() === 'devices'); + expect(devices).toBeDefined(); + const subNames = devices!.commands.map((c) => c.name()); + for (const verb of ['list', 'status', 'describe', 'command', 'batch']) { + expect(subNames).toContain(verb); + } + }); + + it('every subcommand reachable from the program tree is individually serializable', () => { + function walk(cmd: Command): void { + const json = commandToJson(cmd); + expect(json.name).toBeTypeOf('string'); + expect(json.name.length).toBeGreaterThan(0); + expect(Array.isArray(json.arguments)).toBe(true); + expect(Array.isArray(json.options)).toBe(true); + expect(Array.isArray(json.subcommands)).toBe(true); + for (const s of cmd.commands) walk(s); + } + for (const top of program.commands) walk(top); + }); + }); + + describe('root identity — AI discoverability (v2.7.1)', () => { + it('top-level description is the product tagline (smart-home + categories)', () => { + expect(program.description()).toBe(PRODUCT_TAGLINE); + expect(PRODUCT_TAGLINE).toMatch(/SwitchBot/); + expect(PRODUCT_TAGLINE).toMatch(/smart home/i); + // Must not reintroduce "BLE" — CLI only drives Cloud API. + expect(PRODUCT_TAGLINE).not.toMatch(/\bBLE\b/); + }); + + it('root --help --json carries identity (product / domain / vendor / apiVersion / apiDocs / productCategories)', () => { + const json = commandToJson(program, { includeIdentity: true }); + expect(json.product).toBe('SwitchBot'); + expect(json.domain).toMatch(/smart home|IoT/i); + expect(json.vendor).toMatch(/Wonderlabs/); + expect(json.apiVersion).toBe('v1.1'); + expect(json.apiDocs).toMatch(/OpenWonderLabs/); + expect(Array.isArray(json.productCategories)).toBe(true); + expect((json.productCategories ?? []).length).toBeGreaterThan(4); + }); + + it('subcommand --help --json does NOT carry identity fields', () => { + const devices = program.commands.find((c) => c.name() === 'devices')!; + const json = commandToJson(devices); + expect(json.product).toBeUndefined(); + expect(json.domain).toBeUndefined(); + expect(json.productCategories).toBeUndefined(); + }); + }); +}); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 907fddb..2bc2e43 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -132,8 +132,10 @@ describe('devices watch', () => { // Loop exits via --max so parseAsync resolves — exitCode is null. expect(res.exitCode).toBeNull(); const lines = res.stdout.filter((l) => l.trim().startsWith('{')); - expect(lines.length).toBe(1); - const ev = JSON.parse(lines[0]).data; + // P7: first line is the stream header; event is on the second line. + expect(lines.length).toBe(2); + expect(JSON.parse(lines[0]).stream).toBe(true); + const ev = JSON.parse(lines[1]).data; expect(ev.deviceId).toBe('BOT1'); expect(ev.type).toBe('Bot'); expect(ev.tick).toBe(1); @@ -155,7 +157,9 @@ describe('devices watch', () => { const events = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l).data); + .map((l) => JSON.parse(l)) + .filter((j) => !j.stream) + .map((j) => j.data); expect(events).toHaveLength(2); expect(events[0].tick).toBe(1); // Tick 2 should only include the power change — battery stayed 90. @@ -177,7 +181,9 @@ describe('devices watch', () => { const events = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l).data); + .map((l) => JSON.parse(l)) + .filter((j) => !j.stream) + .map((j) => j.data); // Only tick 1 should have emitted (tick 2 had zero changes). expect(events).toHaveLength(1); expect(events[0].tick).toBe(1); @@ -196,7 +202,9 @@ describe('devices watch', () => { const events = res.stdout .filter((l) => l.trim().startsWith('{')) - .map((l) => JSON.parse(l).data); + .map((l) => JSON.parse(l)) + .filter((j) => !j.stream) + .map((j) => j.data); expect(events).toHaveLength(2); expect(Object.keys(events[1].changed)).toHaveLength(0); }, 20_000); @@ -212,12 +220,49 @@ describe('devices watch', () => { ]); expect(res.exitCode).toBeNull(); - const ev = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{'))[0]).data; + const ev = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{') && !l.includes('"stream":true'))[0]).data; expect(ev.changed.power).toBeDefined(); expect(ev.changed.battery).toBeDefined(); expect(ev.changed.temp).toBeUndefined(); }); + // P1 — FIELD_ALIASES dispatch for --fields + it('P1: resolves --fields aliases against first API response (batt → battery)', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90, humidity: 40 } } }); + flagsMock.getFields.mockReturnValueOnce(['batt', 'humid']); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', '--fields', 'batt,humid', + ]); + expect(res.exitCode).toBeNull(); + + const ev = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{') && !l.includes('"stream":true'))[0]).data; + // Only the aliased canonical fields should surface. + expect(ev.changed.battery).toEqual({ from: null, to: 90 }); + expect(ev.changed.humidity).toEqual({ from: null, to: 40 }); + expect(ev.changed.power).toBeUndefined(); + }); + + it('P1: exits 1 (handleError) when --fields names an unknown alias', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90 } } }); + flagsMock.getFields.mockReturnValueOnce(['zombie']); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', '--fields', 'zombie', + ]); + // UsageError during watch is caught by handleError → exit 2. + expect(res.exitCode).toBe(2); + // With --json the envelope is routed to stdout (SYS-1 contract). + const out = res.stdout.join('\n'); + expect(out).toMatch(/zombie/); + const envelope = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).pop()!); + expect(envelope.error.kind).toBe('usage'); + }); + it('continues polling other devices when one errors', async () => { cacheMock.map.set('BOT1', { type: 'Bot', name: 'K1', category: 'physical' }); cacheMock.map.set('BOT2', { type: 'Bot', name: 'K2', category: 'physical' }); @@ -236,7 +281,10 @@ describe('devices watch', () => { const events = [ ...res.stdout.filter((l) => l.trim().startsWith('{')), ...res.stderr.filter((l) => l.trim().startsWith('{')), - ].map((l) => JSON.parse(l).data); + ] + .map((l) => JSON.parse(l)) + .filter((j) => !j.stream) + .map((j) => j.data); expect(events).toHaveLength(2); const byId = Object.fromEntries(events.map((e) => [e.deviceId, e])); expect(byId.BOT1.error).toMatch(/boom/); @@ -258,4 +306,44 @@ describe('devices watch', () => { expect(res.exitCode).toBe(2); expect(res.stderr.join('\n')).toMatch(/--interval.*(duration|look like)/i); }); + + it('P7: emits a streaming JSON header line under --json before any tick', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on' } }, + }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', + ]); + expect(res.exitCode).toBeNull(); + const lines = res.stdout.filter((l) => l.trim().startsWith('{')); + // First line is the stream header; second is the event. + expect(lines.length).toBe(2); + const header = JSON.parse(lines[0]) as { + schemaVersion: string; + stream: boolean; + eventKind: string; + cadence: string; + }; + expect(header.schemaVersion).toBe('1'); + expect(header.stream).toBe(true); + expect(header.eventKind).toBe('tick'); + expect(header.cadence).toBe('poll'); + }); + + it('P7: does NOT emit the stream header in non-JSON mode', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on' } }, + }); + + const res = await runCli(registerDevicesCommand, [ + 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', + ]); + expect(res.exitCode).toBeNull(); + // No JSON lines should be present on stdout in human mode. + const jsonLines = res.stdout.filter((l) => l.trim().startsWith('{')); + expect(jsonLines.length).toBe(0); + }); }); diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index b473594..2c34080 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -6,6 +6,9 @@ import { DEVICE_CATALOG, findCatalogEntry, suggestedActions, + deriveSafetyTier, + getCommandSafetyReason, + type SafetyTier, } from '../../src/devices/catalog.js'; describe('devices/catalog', () => { @@ -48,13 +51,14 @@ describe('devices/catalog', () => { } }); - it('every destructive command has a destructiveReason', () => { + it('every destructive command has a safetyReason (or legacy destructiveReason)', () => { for (const entry of DEVICE_CATALOG) { for (const cmd of entry.commands) { - if (cmd.destructive) { + if (deriveSafetyTier(cmd, entry) === 'destructive') { + const reason = getCommandSafetyReason(cmd); expect( - cmd.destructiveReason, - `${entry.type}.${cmd.command} is destructive but missing destructiveReason` + reason, + `${entry.type}.${cmd.command} is destructive but missing safetyReason/destructiveReason`, ).toBeTypeOf('string'); } } @@ -68,6 +72,12 @@ describe('devices/catalog', () => { return entry?.commands.find((c) => c.command === cmd); }; + const tierOf = (type: string, cmd: string): SafetyTier | undefined => { + const entry = DEVICE_CATALOG.find((e) => e.type === type); + const spec = entry?.commands.find((c) => c.command === cmd); + return entry && spec ? deriveSafetyTier(spec, entry) : undefined; + }; + it('turnOn / turnOff are idempotent across every device type', () => { for (const entry of DEVICE_CATALOG) { for (const c of entry.commands) { @@ -95,24 +105,25 @@ describe('devices/catalog', () => { } }); - it('Smart Lock unlock is destructive', () => { - expect(commandOf('Smart Lock', 'unlock')?.destructive).toBe(true); - expect(commandOf('Smart Lock Lite', 'unlock')?.destructive).toBe(true); - expect(commandOf('Smart Lock Ultra', 'unlock')?.destructive).toBe(true); + it('Smart Lock unlock is safetyTier: destructive', () => { + expect(tierOf('Smart Lock', 'unlock')).toBe('destructive'); + expect(tierOf('Smart Lock Lite', 'unlock')).toBe('destructive'); + expect(tierOf('Smart Lock Ultra', 'unlock')).toBe('destructive'); }); - it('Garage Door Opener turnOn and turnOff are both destructive', () => { - expect(commandOf('Garage Door Opener', 'turnOn')?.destructive).toBe(true); - expect(commandOf('Garage Door Opener', 'turnOff')?.destructive).toBe(true); + it('Garage Door Opener turnOn and turnOff are safetyTier: destructive', () => { + expect(tierOf('Garage Door Opener', 'turnOn')).toBe('destructive'); + expect(tierOf('Garage Door Opener', 'turnOff')).toBe('destructive'); }); - it('Keypad createKey/deleteKey are destructive', () => { - expect(commandOf('Keypad', 'createKey')?.destructive).toBe(true); - expect(commandOf('Keypad', 'deleteKey')?.destructive).toBe(true); + it('Keypad createKey/deleteKey are safetyTier: destructive', () => { + expect(tierOf('Keypad', 'createKey')).toBe('destructive'); + expect(tierOf('Keypad', 'deleteKey')).toBe('destructive'); }); - it('Smart Lock `lock` is NOT destructive', () => { - expect(commandOf('Smart Lock', 'lock')?.destructive).toBeFalsy(); + it('Smart Lock `lock` is mutation, not destructive', () => { + expect(tierOf('Smart Lock', 'lock')).toBe('mutation'); + expect(commandOf('Smart Lock', 'lock')?.safetyTier).toBeUndefined(); }); it('setBrightness / setColor / setColorTemperature carry exampleParams', () => { @@ -127,6 +138,66 @@ describe('devices/catalog', () => { } } }); + + it('every command resolves to one of the 5 safety tiers', () => { + const allowed: SafetyTier[] = ['read', 'mutation', 'ir-fire-forget', 'destructive', 'maintenance']; + for (const entry of DEVICE_CATALOG) { + for (const c of entry.commands) { + const tier = deriveSafetyTier(c, entry); + expect( + allowed.includes(tier), + `${entry.type}.${c.command} derived to unknown tier "${tier}"`, + ).toBe(true); + } + } + }); + + it('every IR entry has ir-fire-forget as its default tier', () => { + for (const entry of DEVICE_CATALOG) { + if (entry.category !== 'ir') continue; + for (const c of entry.commands) { + expect( + deriveSafetyTier(c, entry), + `${entry.type}.${c.command} in IR category should be ir-fire-forget`, + ).toBe('ir-fire-forget'); + } + } + }); + + it('no built-in entry uses "read" or "maintenance" tier today (reserved)', () => { + for (const entry of DEVICE_CATALOG) { + for (const c of entry.commands) { + const tier = deriveSafetyTier(c, entry); + expect(tier).not.toBe('read'); + expect(tier).not.toBe('maintenance'); + } + } + }); + + it('deriveSafetyTier infers destructive from legacy destructive: true', () => { + expect(deriveSafetyTier({ command: 'x', parameter: '-', description: '', destructive: true })) + .toBe('destructive'); + }); + + it('deriveSafetyTier infers ir-fire-forget from commandType: customize', () => { + expect(deriveSafetyTier({ command: 'x', parameter: '-', description: '', commandType: 'customize' })) + .toBe('ir-fire-forget'); + }); + + it('deriveSafetyTier defaults physical to mutation', () => { + expect(deriveSafetyTier({ command: 'x', parameter: '-', description: '' }, { category: 'physical' })) + .toBe('mutation'); + }); + + it('getCommandSafetyReason falls back to legacy destructiveReason', () => { + expect(getCommandSafetyReason({ command: 'x', parameter: '-', description: '', destructiveReason: 'legacy' })) + .toBe('legacy'); + expect(getCommandSafetyReason({ command: 'x', parameter: '-', description: '', safetyReason: 'new' })) + .toBe('new'); + // safetyReason wins over destructiveReason when both are set. + expect(getCommandSafetyReason({ command: 'x', parameter: '-', description: '', safetyReason: 'new', destructiveReason: 'legacy' })) + .toBe('new'); + }); }); describe('role assignments', () => { @@ -357,4 +428,44 @@ describe('catalog overlay', () => { getEffectiveCatalog(); // force overlay application expect(builtin.find((e) => e.type === 'Bot')).toBeDefined(); }); + + // --------------------------------------------------------------------- + // P11: ReadOnlyQuerySpec / deriveStatusQueries + // --------------------------------------------------------------------- + describe('P11: read-tier statusQueries', () => { + it('deriveStatusQueries returns one spec per statusFields entry for physical devices', async () => { + const { deriveStatusQueries, DEVICE_CATALOG: cat } = await import('../../src/devices/catalog.js'); + const bot = cat.find((e) => e.type === 'Bot')!; + const queries = deriveStatusQueries(bot); + expect(queries.length).toBe(bot.statusFields!.length); + for (const q of queries) { + expect(q.safetyTier).toBe('read'); + expect(q.endpoint).toBe('status'); + expect(typeof q.description).toBe('string'); + } + }); + + it('deriveStatusQueries returns [] for IR category entries', async () => { + const { deriveStatusQueries, DEVICE_CATALOG: cat } = await import('../../src/devices/catalog.js'); + const tv = cat.find((e) => e.type === 'TV')!; + expect(deriveStatusQueries(tv)).toEqual([]); + }); + + it('deriveStatusQueries returns [] for entries without statusFields', async () => { + const { deriveStatusQueries } = await import('../../src/devices/catalog.js'); + // Synthetic minimal entry. + const synthetic = { type: 'X', category: 'physical' as const, commands: [] }; + expect(deriveStatusQueries(synthetic)).toEqual([]); + }); + + it('every physical entry with statusFields produces at least one read query', async () => { + const { deriveStatusQueries, DEVICE_CATALOG: cat } = await import('../../src/devices/catalog.js'); + for (const entry of cat) { + if (entry.category !== 'physical' || !entry.statusFields?.length) continue; + const qs = deriveStatusQueries(entry); + expect(qs.length).toBeGreaterThan(0); + for (const q of qs) expect(q.safetyTier).toBe('read'); + } + }); + }); }); diff --git a/tests/devices/resources.test.ts b/tests/devices/resources.test.ts new file mode 100644 index 0000000..8781606 --- /dev/null +++ b/tests/devices/resources.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { + RESOURCE_CATALOG, + listWebhookEventTypes, + listKeyTypes, +} from '../../src/devices/resources.js'; + +describe('RESOURCE_CATALOG', () => { + describe('scenes', () => { + it('declares list / execute / describe operations', () => { + const verbs = RESOURCE_CATALOG.scenes.operations.map((o) => o.verb).sort(); + expect(verbs).toEqual(['describe', 'execute', 'list']); + }); + + it('list is read-tier GET; execute is mutation POST', () => { + const list = RESOURCE_CATALOG.scenes.operations.find((o) => o.verb === 'list')!; + const exec = RESOURCE_CATALOG.scenes.operations.find((o) => o.verb === 'execute')!; + expect(list.safetyTier).toBe('read'); + expect(list.method).toBe('GET'); + expect(exec.safetyTier).toBe('mutation'); + expect(exec.method).toBe('POST'); + }); + + it('execute + describe both require sceneId', () => { + for (const verb of ['execute', 'describe'] as const) { + const op = RESOURCE_CATALOG.scenes.operations.find((o) => o.verb === verb)!; + const sceneId = op.params.find((p) => p.name === 'sceneId'); + expect(sceneId, `${verb} should declare sceneId param`).toBeDefined(); + expect(sceneId!.required).toBe(true); + } + }); + }); + + describe('webhooks', () => { + it('declares setup / query / update / delete endpoints', () => { + const verbs = RESOURCE_CATALOG.webhooks.endpoints.map((e) => e.verb).sort(); + expect(verbs).toEqual(['delete', 'query', 'setup', 'update']); + }); + + it('every endpoint is a POST to a /v1.1/webhook/* path', () => { + for (const ep of RESOURCE_CATALOG.webhooks.endpoints) { + expect(ep.method).toBe('POST'); + expect(ep.path).toMatch(/^\/v1\.1\/webhook\//); + } + }); + + it('delete is destructive; setup + update are mutation; query is read', () => { + const byVerb = Object.fromEntries( + RESOURCE_CATALOG.webhooks.endpoints.map((e) => [e.verb, e.safetyTier]), + ); + expect(byVerb.delete).toBe('destructive'); + expect(byVerb.setup).toBe('mutation'); + expect(byVerb.update).toBe('mutation'); + expect(byVerb.query).toBe('read'); + }); + + it('exposes ~15 event types covering the common device surface', () => { + const events = RESOURCE_CATALOG.webhooks.events; + expect(events.length).toBeGreaterThanOrEqual(10); + const types = events.map((e) => e.eventType); + for (const wanted of ['WoMeter', 'WoPresence', 'WoContact', 'WoLock', 'WoPlug', 'WoDoorbell', 'WoKeypad']) { + expect(types, `missing webhook event ${wanted}`).toContain(wanted); + } + }); + + it('every event declares deviceType, deviceMac, timeOfSample', () => { + for (const ev of RESOURCE_CATALOG.webhooks.events) { + const names = ev.fields.map((f) => f.name); + expect(names, `${ev.eventType} missing deviceType`).toContain('deviceType'); + expect(names, `${ev.eventType} missing deviceMac`).toContain('deviceMac'); + expect(names, `${ev.eventType} missing timeOfSample`).toContain('timeOfSample'); + } + }); + + it('every field has a non-empty description + valid type', () => { + const allowed = new Set(['string', 'number', 'boolean', 'timestamp']); + for (const ev of RESOURCE_CATALOG.webhooks.events) { + for (const f of ev.fields) { + expect(allowed.has(f.type), `${ev.eventType}.${f.name} has invalid type ${f.type}`).toBe(true); + expect(f.description.length).toBeGreaterThan(0); + } + } + }); + + it('constraints expose URL + per-account limits', () => { + expect(RESOURCE_CATALOG.webhooks.constraints.maxUrlLength).toBeGreaterThan(0); + expect(RESOURCE_CATALOG.webhooks.constraints.maxWebhooksPerAccount).toBeGreaterThan(0); + }); + }); + + describe('keys', () => { + it('declares 4 key types: permanent, timeLimit, disposable, urgent', () => { + const types = listKeyTypes().sort(); + expect(types).toEqual(['disposable', 'permanent', 'timeLimit', 'urgent']); + }); + + it('every key type is destructive-tier and lists required params', () => { + for (const k of RESOURCE_CATALOG.keys) { + expect(k.safetyTier).toBe('destructive'); + expect(k.requiredParams).toContain('name'); + expect(k.requiredParams).toContain('password'); + expect(k.supportedDevices.length).toBeGreaterThan(0); + } + }); + + it('timeLimit requires both startTime and endTime', () => { + const tl = RESOURCE_CATALOG.keys.find((k) => k.keyType === 'timeLimit')!; + expect(tl.requiredParams).toContain('startTime'); + expect(tl.requiredParams).toContain('endTime'); + }); + }); + + describe('helper exports', () => { + it('listWebhookEventTypes mirrors the events array', () => { + expect(listWebhookEventTypes()).toEqual( + RESOURCE_CATALOG.webhooks.events.map((e) => e.eventType), + ); + }); + }); +}); diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 3c23e6d..2281c74 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { vi } from 'vitest'; +import { commandToJson, resolveTargetCommand } from '../../src/utils/help-json.js'; export interface RunResult { stdout: string[]; @@ -42,7 +43,7 @@ export async function runCli( program.option('--audit-log'); program.option('--audit-log-path '); program.configureOutput({ - writeOut: (str) => stdout.push(stripTrailingNewline(str)), + writeOut: argv.includes('--json') ? () => {} : (str) => stdout.push(stripTrailingNewline(str)), writeErr: (str) => stderr.push(stripTrailingNewline(str)), }); @@ -81,10 +82,14 @@ export async function runCli( if (isCommanderExit && exitCode === null) { // Mirror production exitOverride in src/index.ts: non-help/version // Commander errors surface as usage errors (exit 2). - if ( - errAsCommander.code === 'commander.helpDisplayed' || - errAsCommander.code === 'commander.version' - ) { + if (errAsCommander.code === 'commander.helpDisplayed') { + // 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.1', data: commandToJson(target) }, null, 2)); + } + exitCode = 0; + } else if (errAsCommander.code === 'commander.version') { exitCode = 0; } else { exitCode = 2; diff --git a/tests/mcp/tool-schema-completeness.test.ts b/tests/mcp/tool-schema-completeness.test.ts new file mode 100644 index 0000000..99626d1 --- /dev/null +++ b/tests/mcp/tool-schema-completeness.test.ts @@ -0,0 +1,155 @@ +/** + * P4: MCP tool schema completeness (N-6 fix-check). + * + * Every registered MCP tool must: + * - have a non-empty title + description at the tool level + * - expose inputSchema as a JSON Schema of type "object" + * - annotate every input property with a non-empty `description` + * (so agents can introspect argument intent without reading source) + * - expose an outputSchema (so the Inspector / clients can verify tool returns) + * + * Tools taking no input ({}) are exempt from the per-property check — there + * are no properties to describe — but the object still must be present. + */ +import { describe, it, expect, vi, beforeAll } from 'vitest'; + +const apiMock = vi.hoisted(() => ({ + createClient: vi.fn(() => ({ get: vi.fn(), post: vi.fn() })), +})); + +vi.mock('../../src/api/client.js', () => ({ + createClient: apiMock.createClient, + ApiError: class ApiError extends Error { + constructor(message: string, public readonly code: number) { + super(message); + this.name = 'ApiError'; + } + }, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + clearCache: vi.fn(), + isListCacheFresh: vi.fn(() => false), + listCacheAgeMs: vi.fn(() => null), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), + clearStatusCache: vi.fn(), + loadStatusCache: vi.fn(() => ({ entries: {} })), + describeCache: vi.fn(() => ({ + list: { path: '', exists: false }, + status: { path: '', exists: false, entryCount: 0 }, + })), +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; + +interface JsonSchemaProp { + type?: string | string[]; + description?: string; + [k: string]: unknown; +} + +interface JsonSchemaObject { + type?: string; + properties?: Record; + required?: string[]; + [k: string]: unknown; +} + +interface ToolShape { + name: string; + title?: string; + description?: string; + inputSchema?: JsonSchemaObject; + outputSchema?: JsonSchemaObject; +} + +describe('MCP tool schema completeness', () => { + let tools: ToolShape[]; + + beforeAll(async () => { + const server = createSwitchBotMcpServer(); + const client = new Client({ name: 'schema-completeness-test', version: '0.0.0' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + const list = await client.listTools(); + tools = list.tools as unknown as ToolShape[]; + }); + + it('at least 10 tools are registered', () => { + expect(tools.length).toBeGreaterThanOrEqual(10); + }); + + it('every tool has a non-empty title and description', () => { + for (const tool of tools) { + expect(tool.title, `${tool.name} missing title`).toBeTypeOf('string'); + expect((tool.title ?? '').length, `${tool.name} empty title`).toBeGreaterThan(0); + expect(tool.description, `${tool.name} missing description`).toBeTypeOf('string'); + expect((tool.description ?? '').length, `${tool.name} empty description`).toBeGreaterThan(0); + } + }); + + it('every tool exposes an inputSchema of type "object"', () => { + for (const tool of tools) { + expect(tool.inputSchema, `${tool.name} missing inputSchema`).toBeDefined(); + expect(tool.inputSchema?.type, `${tool.name} inputSchema.type should be "object"`).toBe('object'); + } + }); + + it('every property of every inputSchema has a non-empty description', () => { + const offenders: string[] = []; + for (const tool of tools) { + const props = tool.inputSchema?.properties; + if (!props) continue; // no inputs — ok + for (const [propName, propSpec] of Object.entries(props)) { + const desc = propSpec.description; + if (typeof desc !== 'string' || desc.trim().length === 0) { + offenders.push(`${tool.name}.${propName}`); + } + } + } + expect( + offenders, + `Tool input properties missing .describe():\n ${offenders.join('\n ')}`, + ).toEqual([]); + }); + + it('every tool exposes an outputSchema (so Inspector / MCP clients can validate returns)', () => { + const offenders: string[] = []; + for (const tool of tools) { + if (!tool.outputSchema || typeof tool.outputSchema !== 'object') { + offenders.push(tool.name); + } + } + expect( + offenders, + `Tools without an outputSchema:\n ${offenders.join('\n ')}`, + ).toEqual([]); + }); + + it('aggregate_device_history describes every input argument (P4 regression guard)', () => { + const agg = tools.find((t) => t.name === 'aggregate_device_history'); + expect(agg, 'aggregate_device_history must be registered').toBeDefined(); + const props = agg!.inputSchema?.properties ?? {}; + const expected = ['deviceId', 'since', 'from', 'to', 'metrics', 'aggs', 'bucket', 'maxBucketSamples']; + for (const prop of expected) { + expect(props[prop], `${prop} should appear in aggregate_device_history inputSchema`).toBeDefined(); + expect( + props[prop].description, + `${prop}.description should be a non-empty string`, + ).toBeTypeOf('string'); + expect((props[prop].description ?? '').length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/schema/field-aliases.test.ts b/tests/schema/field-aliases.test.ts new file mode 100644 index 0000000..c874eb5 --- /dev/null +++ b/tests/schema/field-aliases.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect } from 'vitest'; +import { + FIELD_ALIASES, + resolveField, + resolveFieldList, + listSupportedFieldInputs, + listAllCanonical, +} from '../../src/schema/field-aliases.js'; + +describe('FIELD_ALIASES registry', () => { + it('has at least ~51 canonical keys after P14 expansion', () => { + expect(Object.keys(FIELD_ALIASES).length).toBeGreaterThanOrEqual(51); + }); + + it('never uses reserved/too-generic words as aliases (beyond the grandfathered "type"→deviceType)', () => { + // `type` is grandfathered on deviceType from the identification tier — it predates + // P1's expansion and is already consumed by list-filter parsing. Other reserved + // words are still banned so Phase 2+ fields don't accidentally collide with them. + const forbidden = new Set(['auto', 'status', 'state', 'switch', 'on', 'off', 'lock', 'fan']); + for (const [canonical, aliases] of Object.entries(FIELD_ALIASES)) { + for (const a of aliases) { + expect(forbidden.has(a.toLowerCase()), `"${a}" (under ${canonical}) must not be an alias — it is reserved/too-generic`).toBe(false); + } + } + }); + + it('has no duplicate aliases across canonical keys', () => { + const seen = new Map(); + for (const [canonical, aliases] of Object.entries(FIELD_ALIASES)) { + for (const a of aliases) { + const existing = seen.get(a.toLowerCase()); + expect(existing, `alias "${a}" appears under both "${existing}" and "${canonical}"`).toBeUndefined(); + seen.set(a.toLowerCase(), canonical); + } + } + }); + + it('"temp" resolves only to temperature (not colorTemperature / targetTemperature)', () => { + expect(resolveField('temp', ['temperature', 'colorTemperature', 'targetTemperature'])).toBe('temperature'); + }); + + it('"motion" resolves only to moveDetected (not moving)', () => { + expect(resolveField('motion', ['moveDetected', 'moving'])).toBe('moveDetected'); + }); + + it('"active" resolves only to moving', () => { + expect(resolveField('active', ['moveDetected', 'moving'])).toBe('moving'); + }); + + it('"mode" maps to canonical mode, "devmode" maps to deviceMode', () => { + expect(resolveField('mode', ['mode', 'deviceMode'])).toBe('mode'); + expect(resolveField('devmode', ['mode', 'deviceMode'])).toBe('deviceMode'); + }); + + it('"preset" resolves to mode', () => { + expect(resolveField('preset', ['mode'])).toBe('mode'); + }); + + it('"kelvin" / "colortemp" resolve to colorTemperature (never to temperature)', () => { + expect(resolveField('kelvin', ['temperature', 'colorTemperature'])).toBe('colorTemperature'); + expect(resolveField('colortemp', ['temperature', 'colorTemperature'])).toBe('colorTemperature'); + }); + + it('"enabled" resolves to power (on is not an alias — would conflict with the command)', () => { + expect(resolveField('enabled', ['power'])).toBe('power'); + expect(() => resolveField('on', ['power'])).toThrow(/Unknown/i); + }); +}); + +describe('resolveField() — Phase 1 aliases', () => { + it('battery: batt, bat', () => { + for (const a of ['batt', 'bat', 'BATT', 'Battery']) { + expect(resolveField(a, ['battery'])).toBe('battery'); + } + }); + + it('humidity: humid, rh', () => { + expect(resolveField('humid', ['humidity'])).toBe('humidity'); + expect(resolveField('rh', ['humidity'])).toBe('humidity'); + }); + + it('brightness: bright, bri', () => { + expect(resolveField('bright', ['brightness'])).toBe('brightness'); + expect(resolveField('bri', ['brightness'])).toBe('brightness'); + }); + + it('fanSpeed: speed (not fan)', () => { + expect(resolveField('speed', ['fanSpeed'])).toBe('fanSpeed'); + expect(() => resolveField('fan', ['fanSpeed'])).toThrow(/Unknown/i); + }); + + it('openState: open (not state)', () => { + expect(resolveField('open', ['openState'])).toBe('openState'); + expect(() => resolveField('state', ['openState'])).toThrow(/Unknown/i); + }); + + it('doorState: door', () => { + expect(resolveField('door', ['doorState'])).toBe('doorState'); + }); + + it('position: pos', () => { + expect(resolveField('pos', ['position'])).toBe('position'); + }); + + it('CO2: co2 (case-insensitive)', () => { + expect(resolveField('co2', ['CO2'])).toBe('CO2'); + expect(resolveField('CO2', ['CO2'])).toBe('CO2'); + }); +}); + +describe('resolveField() — Phase 2 aliases', () => { + it('childLock: safe, childlock (never lock)', () => { + expect(resolveField('safe', ['childLock'])).toBe('childLock'); + expect(resolveField('childlock', ['childLock'])).toBe('childLock'); + expect(() => resolveField('lock', ['childLock'])).toThrow(/Unknown/i); + }); + + it('targetTemperature: setpoint, target (not temp)', () => { + expect(resolveField('setpoint', ['targetTemperature'])).toBe('targetTemperature'); + expect(resolveField('target', ['targetTemperature'])).toBe('targetTemperature'); + }); + + it('electricCurrent: current, amps', () => { + expect(resolveField('current', ['electricCurrent'])).toBe('electricCurrent'); + expect(resolveField('amps', ['electricCurrent'])).toBe('electricCurrent'); + }); + + it('voltage: volts', () => { + expect(resolveField('volts', ['voltage'])).toBe('voltage'); + }); + + it('usedElectricity: energy, kwh', () => { + expect(resolveField('energy', ['usedElectricity'])).toBe('usedElectricity'); + expect(resolveField('kwh', ['usedElectricity'])).toBe('usedElectricity'); + }); + + it('electricityOfDay: daily, today', () => { + expect(resolveField('daily', ['electricityOfDay'])).toBe('electricityOfDay'); + expect(resolveField('today', ['electricityOfDay'])).toBe('electricityOfDay'); + }); + + it('version: firmware, fw', () => { + expect(resolveField('firmware', ['version'])).toBe('version'); + expect(resolveField('fw', ['version'])).toBe('version'); + }); + + it('lightLevel: light, lux', () => { + expect(resolveField('light', ['lightLevel'])).toBe('lightLevel'); + expect(resolveField('lux', ['lightLevel'])).toBe('lightLevel'); + }); + + it('oscillation / verticalOscillation resolve separately', () => { + expect(resolveField('swing', ['oscillation', 'verticalOscillation'])).toBe('oscillation'); + expect(resolveField('vswing', ['oscillation', 'verticalOscillation'])).toBe('verticalOscillation'); + }); + + it('chargingStatus: charging, charge', () => { + expect(resolveField('charging', ['chargingStatus'])).toBe('chargingStatus'); + expect(resolveField('charge', ['chargingStatus'])).toBe('chargingStatus'); + }); + + it('switch1Status / switch2Status: ch1 / ch2', () => { + expect(resolveField('ch1', ['switch1Status', 'switch2Status'])).toBe('switch1Status'); + expect(resolveField('ch2', ['switch1Status', 'switch2Status'])).toBe('switch2Status'); + expect(resolveField('channel1', ['switch1Status'])).toBe('switch1Status'); + }); + + it('taskType: task (not type)', () => { + expect(resolveField('task', ['taskType'])).toBe('taskType'); + expect(() => resolveField('type', ['taskType'])).toThrow(/Unknown/i); + }); +}); + +describe('resolveField() — Phase 3 aliases', () => { + it('group: cluster', () => { + expect(resolveField('cluster', ['group'])).toBe('group'); + }); + + it('calibrate: calibration, calib', () => { + expect(resolveField('calibration', ['calibrate'])).toBe('calibrate'); + expect(resolveField('calib', ['calibrate'])).toBe('calibrate'); + }); + + it('direction: tilt', () => { + expect(resolveField('tilt', ['direction'])).toBe('direction'); + }); + + it('nebulizationEfficiency: mist, spray', () => { + expect(resolveField('mist', ['nebulizationEfficiency'])).toBe('nebulizationEfficiency'); + expect(resolveField('spray', ['nebulizationEfficiency'])).toBe('nebulizationEfficiency'); + }); + + it('lackWater: tank, water-low', () => { + expect(resolveField('tank', ['lackWater'])).toBe('lackWater'); + expect(resolveField('water-low', ['lackWater'])).toBe('lackWater'); + }); + + it('color: rgb, hex', () => { + expect(resolveField('rgb', ['color'])).toBe('color'); + expect(resolveField('hex', ['color'])).toBe('color'); + }); + + it('useTime: runtime, uptime', () => { + expect(resolveField('runtime', ['useTime'])).toBe('useTime'); + expect(resolveField('uptime', ['useTime'])).toBe('useTime'); + }); + + it('switchStatus: relay (not switch)', () => { + expect(resolveField('relay', ['switchStatus'])).toBe('switchStatus'); + expect(() => resolveField('switch', ['switchStatus'])).toThrow(/Unknown/i); + }); + + it('lockState: locked', () => { + expect(resolveField('locked', ['lockState'])).toBe('lockState'); + }); + + it('slidePosition: slide', () => { + expect(resolveField('slide', ['slidePosition'])).toBe('slidePosition'); + }); + + it('sound: audio', () => { + expect(resolveField('audio', ['sound'])).toBe('sound'); + }); + + it('filterElement: filter', () => { + expect(resolveField('filter', ['filterElement'])).toBe('filterElement'); + }); +}); + +describe('resolveField() — Phase 4 aliases (ultra-niche)', () => { + it('waterLeakDetect: leak, water', () => { + expect(resolveField('leak', ['waterLeakDetect'])).toBe('waterLeakDetect'); + expect(resolveField('water', ['waterLeakDetect'])).toBe('waterLeakDetect'); + }); + + it('pressure: press, pa', () => { + expect(resolveField('press', ['pressure'])).toBe('pressure'); + expect(resolveField('pa', ['pressure'])).toBe('pressure'); + }); + + it('moveCount: movecnt', () => { + expect(resolveField('movecnt', ['moveCount'])).toBe('moveCount'); + }); + + it('errorCode: err', () => { + expect(resolveField('err', ['errorCode'])).toBe('errorCode'); + }); + + it('buttonName: btn, button', () => { + expect(resolveField('btn', ['buttonName'])).toBe('buttonName'); + expect(resolveField('button', ['buttonName'])).toBe('buttonName'); + }); + + it('pressedAt: pressed (distinct from pressure.press)', () => { + expect(resolveField('pressed', ['pressedAt'])).toBe('pressedAt'); + // `press` goes to pressure, not pressedAt + expect(resolveField('press', ['pressure', 'pressedAt'])).toBe('pressure'); + }); + + it('deviceMac: mac', () => { + expect(resolveField('mac', ['deviceMac'])).toBe('deviceMac'); + }); + + it('detectionState: detected, detect', () => { + expect(resolveField('detected', ['detectionState'])).toBe('detectionState'); + expect(resolveField('detect', ['detectionState'])).toBe('detectionState'); + }); +}); + +describe('resolveField() — error paths', () => { + it('throws on empty input', () => { + expect(() => resolveField('', ['battery'])).toThrow(/empty/i); + expect(() => resolveField(' ', ['battery'])).toThrow(/empty/i); + }); + + it('throws on unknown field with candidate list', () => { + let err: Error | null = null; + try { resolveField('zombie', ['battery', 'humidity']); } catch (e) { err = e as Error; } + expect(err).not.toBeNull(); + expect(err!.message).toContain('zombie'); + expect(err!.message).toContain('battery'); + expect(err!.message).toContain('humidity'); + }); + + it('does not resolve an alias whose canonical is not in the allowed list', () => { + // `batt` would map to `battery`, but `battery` is not allowed here. + expect(() => resolveField('batt', ['humidity', 'CO2'])).toThrow(/Unknown/i); + }); + + it('prefers direct canonical match over alias match when both possible', () => { + // Edge: if someone registered an alias that matched another canonical name, + // the canonical check runs first so we never return the aliased-canonical. + expect(resolveField('battery', ['battery', 'humidity'])).toBe('battery'); + }); +}); + +describe('resolveFieldList()', () => { + it('resolves a list of mixed alias + canonical inputs', () => { + expect(resolveFieldList(['batt', 'humid', 'power'], ['battery', 'humidity', 'power'])) + .toEqual(['battery', 'humidity', 'power']); + }); + + it('preserves input order', () => { + expect(resolveFieldList(['power', 'batt'], ['battery', 'power'])) + .toEqual(['power', 'battery']); + }); + + it('throws on first unknown input', () => { + expect(() => resolveFieldList(['batt', 'zombie', 'humid'], ['battery', 'humidity'])) + .toThrow(/zombie/); + }); +}); + +describe('listSupportedFieldInputs() / listAllCanonical()', () => { + it('lists canonicals + their aliases for the allowed subset', () => { + const out = listSupportedFieldInputs(['battery', 'humidity']); + expect(out).toContain('battery'); + expect(out).toContain('batt'); + expect(out).toContain('humidity'); + expect(out).toContain('rh'); + }); + + it('listAllCanonical returns every canonical in the registry', () => { + const all = listAllCanonical(); + expect(all).toContain('deviceId'); + expect(all).toContain('battery'); + expect(all).toContain('switchStatus'); + expect(all.length).toBe(Object.keys(FIELD_ALIASES).length); + }); +}); diff --git a/tests/utils/help-json.test.ts b/tests/utils/help-json.test.ts new file mode 100644 index 0000000..9f83b4e --- /dev/null +++ b/tests/utils/help-json.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { commandToJson, resolveTargetCommand } from '../../src/utils/help-json.js'; + +describe('commandToJson', () => { + it('serializes name, description, arguments, options, subcommands', () => { + const cmd = new Command('test') + .description('Test command') + .argument('', 'A required bar argument') + .argument('[baz]', 'An optional baz argument') + .option('--foo ', 'A foo option', 'default-foo') + .option('--flag', 'A boolean flag'); + cmd.command('sub').description('A subcommand'); + + const result = commandToJson(cmd); + expect(result.name).toBe('test'); + expect(result.description).toBe('Test command'); + expect(result.arguments).toEqual([ + { name: 'bar', required: true, variadic: false, description: 'A required bar argument' }, + { name: 'baz', required: false, variadic: false, description: 'An optional baz argument' }, + ]); + expect(result.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ flags: '--foo ', description: 'A foo option', defaultValue: 'default-foo' }), + expect.objectContaining({ flags: '--flag', description: 'A boolean flag' }), + ]) + ); + expect(result.subcommands).toEqual([{ name: 'sub', description: 'A subcommand' }]); + }); + + it('excludes --help and --version from options', () => { + const cmd = new Command('root').version('1.0.0').option('--json', 'JSON mode'); + const result = commandToJson(cmd); + const flags = result.options.map((o) => o.flags); + expect(flags).not.toContain('-h, --help'); + expect(flags).not.toContain('-V, --version'); + expect(flags).toContain('--json'); + }); + + it('includes choices when defined', () => { + const cmd = new Command('test'); + cmd.addOption( + new (require('commander').Option)('--format ', 'Output format').choices(['json', 'table', 'tsv']) + ); + const result = commandToJson(cmd); + const formatOpt = result.options.find((o) => o.flags.includes('--format')); + expect(formatOpt?.choices).toEqual(['json', 'table', 'tsv']); + }); + + it('omits identity fields by default', () => { + const cmd = new Command('switchbot').description('root'); + const result = commandToJson(cmd); + expect(result.product).toBeUndefined(); + expect(result.domain).toBeUndefined(); + expect(result.vendor).toBeUndefined(); + expect(result.apiVersion).toBeUndefined(); + expect(result.apiDocs).toBeUndefined(); + expect(result.productCategories).toBeUndefined(); + }); + + it('includes identity fields when {includeIdentity:true} is passed (root --help --json)', () => { + const cmd = new Command('switchbot').description('root'); + const result = commandToJson(cmd, { includeIdentity: true }); + expect(result.product).toBe('SwitchBot'); + expect(result.domain).toMatch(/smart home|IoT/i); + expect(result.vendor).toBe('Wonderlabs, Inc.'); + expect(result.apiVersion).toBe('v1.1'); + expect(result.apiDocs).toMatch(/OpenWonderLabs/); + expect(Array.isArray(result.productCategories)).toBe(true); + expect(result.productCategories!.length).toBeGreaterThan(0); + // AI discoverability: product categories should name common device kinds. + const joined = (result.productCategories ?? []).join(' | ').toLowerCase(); + expect(joined).toMatch(/light/); + expect(joined).toMatch(/lock/); + expect(joined).toMatch(/curtain/); + expect(joined).toMatch(/ir/); + }); +}); + +describe('resolveTargetCommand', () => { + it('returns root when no subcommand matches', () => { + const root = new Command('switchbot'); + const result = resolveTargetCommand(root, ['--json', '--help']); + expect(result.name()).toBe('switchbot'); + }); + + it('descends into a matching subcommand', () => { + const root = new Command('switchbot'); + const devices = root.command('devices').description('Devices'); + devices.command('list').description('List devices'); + + const result = resolveTargetCommand(root, ['devices', '--help', '--json']); + expect(result.name()).toBe('devices'); + }); + + it('descends into a nested subcommand', () => { + const root = new Command('switchbot'); + const devices = root.command('devices'); + devices.command('list'); + + const result = resolveTargetCommand(root, ['devices', 'list', '--help']); + expect(result.name()).toBe('list'); + }); + + it('resolves command aliases', () => { + const root = new Command('switchbot'); + root.command('devices').alias('d'); + + const result = resolveTargetCommand(root, ['d', '--help']); + expect(result.name()).toBe('devices'); + }); +});