From 8d7fb78097282b5318f9b7697aa8b04eca00c2cc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 20:42:29 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20v2.7=20AI-first=20maturity=20?= =?UTF-8?q?=E2=80=94=20filter=20fields,=20help=20JSON,=20destructive=20nor?= =?UTF-8?q?malization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three smoke-v3 red-dimension fixes: 1. Expand --filter to all 11 device fields - LIST_FILTER_CANONICAL now includes familyName, hubDeviceId, roomID, enableCloudService, alias (was only 6 keys) - LIST_KEYS and LIST_FILTER_TO_RUNTIME updated to match - All 4 matchesFilter() call sites pass new fields 2. Add --help --json structured output - New src/utils/help-json.ts: commandToJson() + resolveTargetCommand() - src/index.ts: suppress plain-text help in JSON mode; emit structured JSON on commander.helpDisplayed - tests/helpers/cli.ts: mirror the same interception so integration tests can exercise --help --json paths 3. Normalize destructive: boolean on all command JSON outputs - devices commands --json: normalizeCatalogForJson() coerces undefined → false - devices describe --json: same coercion in describeDevice() capabilities assembly - schema export already did this; now all three entry points are consistent --- src/commands/devices.ts | 39 ++++++++++++---- src/index.ts | 17 ++++++- src/lib/devices.ts | 2 +- src/utils/help-json.ts | 75 ++++++++++++++++++++++++++++++ tests/commands/devices.test.ts | 83 ++++++++++++++++++++++++++++++++++ tests/helpers/cli.ts | 15 ++++-- tests/utils/help-json.test.ts | 83 ++++++++++++++++++++++++++++++++++ 7 files changed, 296 insertions(+), 18 deletions(-) create mode 100644 src/utils/help-json.ts create mode 100644 tests/utils/help-json.test.ts diff --git a/src/commands/devices.ts b/src/commands/devices.ts index a21cdda..46bd4ef 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -103,7 +103,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 +115,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 +127,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 +149,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 +165,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 +186,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 +205,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, @@ -682,7 +694,7 @@ Examples: const joinedMatch = findCatalogEntry(joined); if (joinedMatch && !Array.isArray(joinedMatch)) { if (isJsonMode()) { - printJson(joinedMatch); + printJson(normalizeCatalogForJson(joinedMatch)); } else { renderCatalogEntry(joinedMatch); } @@ -701,7 +713,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 +883,13 @@ Examples: registerDevicesMetaCommand(devices); } +function normalizeCatalogForJson(entry: DeviceCatalogEntry): object { + return { + ...entry, + commands: entry.commands.map((c) => ({ ...c, destructive: Boolean(c.destructive) })), + }; +} + function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log(`Type: ${entry.type}`); console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`); diff --git a/src/index.ts b/src/index.ts index a37d5e8..6937327 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,8 @@ 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 { registerConfigCommand } from './commands/config.js'; import { registerDevicesCommand } from './commands/devices.js'; import { registerScenesCommand } from './commands/scenes.js'; @@ -164,6 +165,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 +177,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)); + } + 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..943fdd2 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -382,7 +382,7 @@ export async function describeDevice( ? { role: catalogEntry.role ?? null, readOnly: catalogEntry.readOnly ?? false, - commands: catalogEntry.commands, + commands: catalogEntry.commands.map((c) => ({ ...c, destructive: Boolean(c.destructive) })), statusFields: catalogEntry.statusFields ?? [], ...(liveStatus !== undefined ? { liveStatus } : {}), } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts new file mode 100644 index 0000000..e649b4a --- /dev/null +++ b/src/utils/help-json.ts @@ -0,0 +1,75 @@ +import type { Command, Option, Argument } from 'commander'; + +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; + arguments: ArgJson[]; + options: OptionJson[]; + subcommands: SubcommandJson[]; +} + +export function commandToJson(cmd: Command): CommandJson { + const args: ArgJson[] = (cmd.registeredArguments as Argument[]).map((a) => ({ + name: a.name(), + required: a.required, + variadic: a.variadic, + description: a.description ?? '', + })); + + const opts: 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 subs: SubcommandJson[] = cmd.commands + .filter((c) => !c.name().startsWith('_')) + .map((c) => ({ name: c.name(), description: c.description() })); + + return { + name: cmd.name(), + description: cmd.description(), + arguments: args, + options: opts, + subcommands: subs, + }; +} + +/** 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/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 40b5928..1c60a85 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'); + }); }); // ===================================================================== @@ -2276,4 +2308,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/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/utils/help-json.test.ts b/tests/utils/help-json.test.ts new file mode 100644 index 0000000..4e3afc0 --- /dev/null +++ b/tests/utils/help-json.test.ts @@ -0,0 +1,83 @@ +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']); + }); +}); + +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'); + }); +}); From 2dbcb6a3deb1c9f5b3581814d49f1eba676161f2 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 22:36:42 +0800 Subject: [PATCH 02/16] feat(field-aliases): dispatch + expand to ~55 alias groups (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire resolveField() into `devices status` and `devices watch` so user-typed --fields aliases resolve to canonical API keys instead of silently returning null. Unknown fields now exit 2 with a candidate list. Expand FIELD_ALIASES from 10 identification keys to ~55 total: - Phase 1 (13): battery, temperature, colorTemperature, humidity, brightness, fanSpeed, position, moveDetected, openState, doorState, CO2, power, mode - Phase 2 (19): childLock, targetTemperature, electricCurrent, voltage, usedElectricity, electricityOfDay, weight, version, lightLevel, oscillation, verticalOscillation, nightStatus, chargingStatus, switch1Status, switch2Status, taskType, moving, onlineStatus, workingStatus - Phase 3 (13): group, calibrate, direction, deviceMode, nebulizationEfficiency, sound, lackWater, filterElement, color, useTime, switchStatus, lockState, slidePosition Conflict rules enforced by tests: - `temp` exclusive to temperature (not colorTemperature / targetTemperature) - `motion` → moveDetected only; `moving` uses `active` - `mode` → top-level mode; device-specific goes through deviceMode - Reserved words (auto, status, state, switch, on, off, lock, fan) are never aliases. `type` is grandfathered on deviceType. +60 tests covering every new alias, conflict rules, dispatch behavior, and UsageError paths. No regressions — 1089 tests pass. --- src/commands/devices.ts | 17 +- src/commands/watch.ts | 11 +- src/schema/field-aliases.ts | 93 +++++++++ tests/commands/devices.test.ts | 85 +++++++++ tests/commands/watch.test.ts | 37 ++++ tests/schema/field-aliases.test.ts | 290 +++++++++++++++++++++++++++++ 6 files changed, 527 insertions(+), 6 deletions(-) create mode 100644 tests/schema/field-aliases.test.ts diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 46bd4ef..c480616 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -27,7 +27,7 @@ import { registerExpandCommand } from './expand.js'; import { registerDevicesMetaCommand } from './device-meta.js'; import { isDryRun } from '../utils/flags.js'; import { DryRunSignal } from '../api/client.js'; -import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js'; +import { resolveField, resolveFieldList, listSupportedFieldInputs } from '../schema/field-aliases.js'; const EXPAND_HINTS: Record = { 'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' }, @@ -307,16 +307,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)}`); } @@ -344,7 +348,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; } diff --git a/src/commands/watch.ts b/src/commands/watch.ts index b3d2f45..58c10ab 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -6,6 +6,7 @@ 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(); diff --git a/src/schema/field-aliases.ts b/src/schema/field-aliases.ts index b6327e0..e986192 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,66 @@ 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'], }; +/** + * 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 +97,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 +107,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 +129,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/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 1c60a85..d6a6c49 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -699,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'); + }); + }); }); // ===================================================================== diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 907fddb..71b4163 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -218,6 +218,43 @@ describe('devices watch', () => { 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('{'))[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' }); diff --git a/tests/schema/field-aliases.test.ts b/tests/schema/field-aliases.test.ts new file mode 100644 index 0000000..f04d6ff --- /dev/null +++ b/tests/schema/field-aliases.test.ts @@ -0,0 +1,290 @@ +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 ~43 canonical keys after P1 expansion', () => { + expect(Object.keys(FIELD_ALIASES).length).toBeGreaterThanOrEqual(43); + }); + + 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() — 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); + }); +}); From b75be1f9bf88529bd6547f125b4c912aff6ff0be Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 22:48:06 +0800 Subject: [PATCH 03/16] refactor(catalog): replace destructive:boolean with safetyTier 5-tier enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce SafetyTier union ('read' | 'mutation' | 'ir-fire-forget' | 'destructive' | 'maintenance') and migrate the 7 destructive catalog entries (Smart Lock x3 unlock, Garage Door Opener turnOn/turnOff, Keypad createKey/deleteKey) to use safetyTier + safetyReason. The legacy destructive:boolean / destructiveReason fields are retained on CommandSpec as @deprecated for overlay back-compat; deriveSafetyTier handles both forms. Output layers (capabilities, schema, describe, explain, agent-bootstrap, MCP search_catalog) emit safetyTier alongside a derived destructive:boolean for v2.6 consumer compatibility, to be removed in v3.0. capabilities JSON now exposes safetyTiersInUse (sorted unique set of tiers present in the effective catalog). 'read' and 'maintenance' are reserved — no built-in entries use them yet (P11 will populate 'read' via statusQueries; 'maintenance' awaits SwitchBot API endpoints). tests: tests/devices/catalog.test.ts extended with tier validity, IR→ir-fire-forget, derivation fallbacks, and safetyReason fallback (new wins over legacy); full suite 1096/1096 green. --- src/commands/agent-bootstrap.ts | 18 +++--- src/commands/capabilities.ts | 26 +++++++- src/commands/catalog.ts | 4 +- src/commands/devices.ts | 28 +++++++-- src/commands/explain.ts | 26 +++++--- src/commands/mcp.ts | 27 +++++++-- src/commands/schema.ts | 42 +++++++++---- src/devices/catalog.ts | 89 +++++++++++++++++++++++---- src/lib/devices.ts | 20 +++++-- tests/devices/catalog.test.ts | 103 +++++++++++++++++++++++++++----- 10 files changed, 313 insertions(+), 70 deletions(-) diff --git a/src/commands/agent-bootstrap.ts b/src/commands/agent-bootstrap.ts index a7d65d0..296f649 100644 --- a/src/commands/agent-bootstrap.ts +++ b/src/commands/agent-bootstrap.ts @@ -1,7 +1,7 @@ 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 } 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'; @@ -107,12 +107,16 @@ 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 ?? [], }; }); diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index cc5b0d3..5bd7c72 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -1,9 +1,25 @@ import { Command } from 'commander'; -import { getEffectiveCatalog } from '../devices/catalog.js'; +import { + getEffectiveCatalog, + deriveSafetyTier, + type DeviceCatalogEntry, + type SafetyTier, +} from '../devices/catalog.js'; import { loadCache } from '../devices/cache.js'; import { printJson } from '../utils/output.js'; import { enumArg, stringArg } from '../utils/arg-parsers.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)); + } + } + return [...seen].sort(); +} + export type AgentSafetyTier = 'read' | 'action' | 'destructive'; export type Verifiability = 'local' | 'deviceConfirmed' | 'deviceDependent' | 'none'; @@ -286,9 +302,11 @@ 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, }, }; @@ -313,9 +331,11 @@ 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, }; payload.usedFilter = { applied: true, typesInCache: [...seen].sort() }; diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index df7f1cf..dca4913 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'; @@ -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/devices.ts b/src/commands/devices.ts index c480616..98af588 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -2,7 +2,13 @@ import { Command } from 'commander'; import { enumArg, stringArg } from '../utils/arg-parsers.js'; import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError, exitWithError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; -import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; +import { + findCatalogEntry, + getEffectiveCatalog, + deriveSafetyTier, + getCommandSafetyReason, + DeviceCatalogEntry, +} from '../devices/catalog.js'; import { getCachedDevice, loadCache } from '../devices/cache.js'; import { loadDeviceMeta } from '../devices/device-meta.js'; import { resolveDeviceId, NameResolveStrategy, ALL_STRATEGIES } from '../utils/name-resolver.js'; @@ -554,7 +560,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 } : {}) }, }); } @@ -893,7 +899,16 @@ Examples: function normalizeCatalogForJson(entry: DeviceCatalogEntry): object { return { ...entry, - commands: entry.commands.map((c) => ({ ...c, destructive: Boolean(c.destructive) })), + commands: entry.commands.map((c) => { + const tier = deriveSafetyTier(c, entry); + const reason = getCommandSafetyReason(c); + return { + ...c, + safetyTier: tier, + destructive: tier === 'destructive', + ...(reason ? { safetyReason: reason } : {}), + }; + }), }; } @@ -912,9 +927,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; @@ -923,7 +939,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/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/mcp.ts b/src/commands/mcp.ts index 94c8bd4..e7232bd 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -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, }; } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index d3f0d43..31dec81 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -1,7 +1,14 @@ 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 { loadCache } from '../devices/cache.js'; interface SchemaEntry { @@ -17,7 +24,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 +43,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 +58,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 +85,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 ?? [], }; } diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 43983de..ac52a2f 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -7,23 +7,58 @@ * - 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'. */ +/** + * 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[]; } @@ -104,7 +139,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 +151,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 +162,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 +381,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 +404,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 +619,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 +657,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/lib/devices.ts b/src/lib/devices.ts index 943fdd2..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.map((c) => ({ ...c, destructive: Boolean(c.destructive) })), + 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/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index b473594..b9c82b0 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', () => { From 1d5d197902696a7c6fd8dce0b10ce5c284de5a66 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 22:49:22 +0800 Subject: [PATCH 04/16] test(help-json): contract coverage for all 16 top-level commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Table-driven describe.each suite that registers every top-level command through its real register* function, walks the commander tree, and asserts structural invariants — not text snapshots, so wording drift does not break CI. For each command the contract checks: - name matches the registered string and is non-empty - description is a non-empty string - arguments / options / subcommands are arrays - each option carries flags + description; --help and --version are filtered out - every subcommand has a non-empty name and non-empty description - the full subtree is individually serializable via commandToJson Added separately from tests/utils/help-json.test.ts (unit-level), so the contract test is the canonical guard against a new subcommand landing without a description or a command ID drifting from its registration. --- tests/commands/help-json-contract.test.ts | 157 ++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/commands/help-json-contract.test.ts diff --git a/tests/commands/help-json-contract.test.ts b/tests/commands/help-json-contract.test.ts new file mode 100644 index 0000000..8162351 --- /dev/null +++ b/tests/commands/help-json-contract.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { Command } from 'commander'; +import { commandToJson, type CommandJson } from '../../src/utils/help-json.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('Command-line tool for SwitchBot API v1.1').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); + }); + }); +}); From 7b59ed30ade25911ec6a1ad2f0b776bad6b6d354 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 22:52:48 +0800 Subject: [PATCH 05/16] =?UTF-8?q?refactor(mcp):=20complete=20tool=20schema?= =?UTF-8?q?s=20=E2=80=94=20describe=20all=20inputs,=20type=20outputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aggregate_device_history was missing .describe() on every input field and shipping no outputSchema at all, so the MCP Inspector (and downstream clients using structuredContent validation) could not introspect or validate its response. Fixed by: - documenting every input field (deviceId, since, from, to, metrics, aggs, bucket, maxBucketSamples) with non-empty descriptions - adding a fully-typed outputSchema mirroring AggResult (deviceId, bucket?, from, to, metrics, aggs, buckets[], partial, notes) - replacing the Record cast with an explicit structured object so omitted `bucket` stays omitted Added tests/mcp/tool-schema-completeness.test.ts as a regression guard: walks every registered tool via the InMemory transport and asserts non-empty title/description, inputSchema of type "object", a non-empty description on every input property, and the presence of an outputSchema. One surgical assertion spot-checks every aggregate_device_history input so a future drop of .describe() breaks CI instantly. No stdio JSON-RPC log churn this round: the HTTP-mode console.error calls at mcp.ts:988/991 are stderr-safe and low-severity per the plan. --- src/commands/mcp.ts | 82 +++++++++-- tests/mcp/tool-schema-completeness.test.ts | 155 +++++++++++++++++++++ 2 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 tests/mcp/tool-schema-completeness.test.ts diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index e7232bd..201e762 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -742,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 = { @@ -764,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, }; }, ); 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); + } + }); +}); From ca7c23dc963545cae7aecc31f8059a66d4cce321 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 22:59:02 +0800 Subject: [PATCH 06/16] refactor(errors): route all errors through exitWithError / handleError (P5) Remove the bypass pattern if (isJsonMode()) emitJsonError({...}); else console.error(...); process.exit(N); in favour of the single-call exitWithError({code, kind, message, hint?, context?}) helper that already centralises JSON envelope + plain-text + exit. Touched: config set-token (5 sites), history replay, devices command validation, batch destructive guard, expand destructive guard. Unused emitJsonError imports cleaned up across batch / devices / history / mcp. Add tests/commands/error-envelope.test.ts (N-3 regression guard): - envelope shape { schemaVersion, error } under --json - stderr-only output in plain mode - runtime kind + non-2 exit codes - textual audit that no command source pairs emitJsonError() with process.exit() in the same module (except mcp.ts signal handlers) --- src/commands/batch.ts | 26 ++-- src/commands/config.ts | 60 ++++---- src/commands/devices.ts | 36 +++-- src/commands/expand.ts | 20 +-- src/commands/history.ts | 26 ++-- src/commands/mcp.ts | 2 +- tests/commands/error-envelope.test.ts | 188 ++++++++++++++++++++++++++ 7 files changed, 259 insertions(+), 99 deletions(-) create mode 100644 tests/commands/error-envelope.test.ts diff --git a/src/commands/batch.ts b/src/commands/batch.ts index fa69fc1..50bfde2 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, @@ -296,22 +296,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. 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/ = { 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. 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/history.ts b/src/commands/history.ts index 84a54f7..38e77dc 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 { @@ -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/mcp.ts b/src/commands/mcp.ts index 201e762..d9be6b3 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, 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; From 9c5a8040654b40afdf16ec7a9f825e1bbb7e9136 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:03:30 +0800 Subject: [PATCH 07/16] feat(events): unified envelope across tail / mqtt-tail (P6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: `events tail` emitted {t, remote, path, body, matched:bool}, while `events mqtt-tail` emitted {t, eventId, topic, payload} for events and {type:"__connect", at, eventId} for control records. Downstream consumers had to key on field presence to tell webhook apart from mqtt, and the matched-bool on webhook gave no clue which filter clause hit. After: both sides add an overlapping envelope keyed on { schemaVersion: "1", source: "webhook"|"mqtt", kind: "event"|"control", t, eventId, deviceId, topic, payload } Webhook additionally carries matchedKeys:string[] — exact list of clause keys that matched, or [] for no filter / no match. MQTT control records gain controlKind ("connect"|"reconnect"|"disconnect"|"heartbeat"| "session_start") while keeping the legacy "type":"__connect" / "at" fields for one minor window (removed in v3.0). - src/commands/events.ts: startReceiver emits unified + legacy mirror; mqtt-tail event + control lines carry the unified envelope. - src/mcp/device-history.ts: ControlEvent extended with optional schemaVersion/source/kind/controlKind/t so __control.jsonl round-trips. - tests/commands/events.test.ts: 4 new tests — webhook envelope, matchedKeys emission, mqtt event envelope, mqtt control envelope. - addHelpText for both subcommands updated to describe the new shape + legacy deprecation schedule. --- src/commands/events.ts | 139 ++++++++++++++++++++++++++++------ src/mcp/device-history.ts | 8 ++ tests/commands/events.test.ts | 115 ++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 24 deletions(-) diff --git a/src/commands/events.ts b/src/commands/events.ts index 0e18c28..9c6443a 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -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 @@ -269,14 +321,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 @@ -391,11 +449,18 @@ Examples: // 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 +500,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 +526,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/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/tests/commands/events.test.ts b/tests/commands/events.test.ts index cc29816..7f23c6b 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([]); + }); }); // --------------------------------------------------------------------------- @@ -375,6 +433,63 @@ describe('events mqtt-tail', () => { // never connects. expect((jsonLines[0] as { data: { type?: string } }).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); + }); }); // --------------------------------------------------------------------------- From ac682cd5e01b3acc802bd091dc6da522ae27e6a5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:11:15 +0800 Subject: [PATCH 08/16] feat(streaming): schemaVersion header for NDJSON streams + docs (P7) All three streaming commands (devices watch, events tail, events mqtt-tail) now emit a stream-header record as the very first JSON line under --json: { "schemaVersion": "1", "stream": true, "eventKind": "tick" | "event", "cadence": "poll" | "push" } Downstream consumers can route by `{ stream: true }` to distinguish the header from subsequent event lines and pick a parser strategy based on `eventKind` / `cadence`. Non-streaming commands (single- object / array output) are untouched. New `docs/json-contract.md` documents both envelope shapes (non- streaming success/error vs. streaming header + event lines), the two versioning axes, and consumer routing patterns. P7 from the v2.7.0 AI-first maturity plan (N-4). --- docs/json-contract.md | 189 ++++++++++++++++++++++++++++++++++ src/commands/events.ts | 9 +- src/commands/watch.ts | 6 +- src/utils/output.ts | 23 +++++ tests/commands/events.test.ts | 59 +++++++++-- tests/commands/watch.test.ts | 67 ++++++++++-- 6 files changed, 333 insertions(+), 20 deletions(-) create mode 100644 docs/json-contract.md 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/src/commands/events.ts b/src/commands/events.ts index 9c6443a..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'; @@ -252,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 { @@ -445,6 +448,10 @@ 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. diff --git a/src/commands/watch.ts b/src/commands/watch.ts index 58c10ab..cc4b338 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,5 +1,5 @@ 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'; @@ -156,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/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/commands/events.test.ts b/tests/commands/events.test.ts index 7f23c6b..418350f 100644 --- a/tests/commands/events.test.ts +++ b/tests/commands/events.test.ts @@ -361,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 () => { @@ -423,15 +433,27 @@ 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 () => { @@ -490,6 +512,23 @@ describe('events mqtt-tail', () => { // 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/watch.test.ts b/tests/commands/watch.test.ts index 71b4163..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,7 +220,7 @@ 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(); @@ -230,7 +238,7 @@ 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; // 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 }); @@ -273,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/); @@ -295,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); + }); }); From 02703254ecb21a968533f9e170083ce5e5df9d9f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:13:09 +0800 Subject: [PATCH 09/16] fix(quota): record all API call attempts, not only successes (P8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `recordRequest()` fired from the axios response interceptor — so successful responses and exhausted HTTP retries counted against the daily quota, but timeouts, DNS errors, connection refusals, and requests aborted after dispatch were silently missed. The local cap was therefore optimistic versus the real SwitchBot billing. Move the call to the request interceptor so every dispatched HTTP request counts at send time, regardless of outcome. Pre-flight refusals (daily-cap, --dry-run) still skip recording because they never touch the network. Retries re-enter the interceptor and record each attempt, which matches how SwitchBot bills. New tests cover the four paths: successful dispatch records once; 5xx after dispatch stays at one count (no double-record in the error interceptor); timeouts record even though no response arrives; `--no-quota` suppresses the record entirely. P8 from the v2.7.0 AI-first maturity plan (N-5). --- src/api/client.ts | 26 +++++++------ tests/api/client.test.ts | 84 +++++++++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 30 deletions(-) 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/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(); }); }); From 6173da3b591a1567ac823105f2351b62277aa4e8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:17:51 +0800 Subject: [PATCH 10/16] feat(doctor): quota headroom + catalog-schema + audit checks (P9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quota check now exposes percentUsed / remaining / projectedResetTime / recommendation so agents can decide to slow down or pause; warn when >80%. - New catalog-schema check detects drift between catalog schemaVersion and the agent-bootstrap payload version (paired constants in both modules). - New audit check surfaces recent command failures from ~/.switchbot/audit.log (last 24h), capped at 10 entries to keep the doctor payload bounded. All additive under the locked doctor.schemaVersion=1 contract — existing consumers unaffected. --- src/commands/agent-bootstrap.ts | 17 +++- src/commands/doctor.ts | 152 +++++++++++++++++++++++++++++++- src/devices/catalog.ts | 9 ++ tests/commands/doctor.test.ts | 100 +++++++++++++++++++++ 4 files changed, 273 insertions(+), 5 deletions(-) diff --git a/src/commands/agent-bootstrap.ts b/src/commands/agent-bootstrap.ts index 296f649..0270bbf 100644 --- a/src/commands/agent-bootstrap.ts +++ b/src/commands/agent-bootstrap.ts @@ -1,7 +1,11 @@ import { Command } from 'commander'; import { printJson } from '../utils/output.js'; import { loadCache } from '../devices/cache.js'; -import { getEffectiveCatalog, deriveSafetyTier } 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'; @@ -10,6 +14,15 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { version: pkgVersion } = require('../../package.json') as { version: string }; +/** + * Schema version of the agent-bootstrap payload. Must stay in lockstep + * with the catalog schema — bootstrap consumers (AI agents) reason about + * catalog-derived fields (safetyTier, destructive flag), so a drift + * between the two would silently break their assumptions. `doctor` + * fails the `catalog-schema` check when these differ. + */ +export const AGENT_BOOTSTRAP_SCHEMA_VERSION = CATALOG_SCHEMA_VERSION; + const IDENTITY = { product: 'SwitchBot', domain: 'IoT smart home device control', @@ -122,7 +135,7 @@ Examples: }); const payload: Record = { - schemaVersion: '1.0', + schemaVersion: AGENT_BOOTSTRAP_SCHEMA_VERSION, generatedAt: new Date().toISOString(), cliVersion: pkgVersion, identity: IDENTITY, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f290693..13ddf5c 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -6,6 +6,9 @@ import { printJson, isJsonMode } from '../utils/output.js'; import { getEffectiveCatalog } from '../devices/catalog.js'; import { configFilePath, listProfiles, readProfileMeta } from '../config.js'; import { describeCache } 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'; interface Check { name: string; @@ -169,14 +172,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)}`, + }, + }; } } @@ -238,10 +382,12 @@ Examples: await checkCredentials(), checkProfiles(), checkCatalog(), + checkCatalogSchema(), checkCache(), checkQuotaFile(), await checkClockSkew(), checkMqtt(), + checkAudit(), ]; const summary = { ok: checks.filter((c) => c.status === 'ok').length, diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index ac52a2f..b5d773e 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -19,6 +19,15 @@ * 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. * diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 9759ded..9178305 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -162,4 +162,104 @@ 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'); + }); }); From ff3accce7af252a04e36001b3a2215e20886a528 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:21:36 +0800 Subject: [PATCH 11/16] feat(doctor): MQTT live-probe + MCP dry-run + --section/--list/--fix (P10) - New `mcp` check: dry-run instantiates createSwitchBotMcpServer() and counts registered tools (no network I/O, no token needed). Fails when server construction throws. - `mqtt` check gains `--probe` variant that does a real broker handshake (fetchMqttCredential + connect + disconnect), with a 5s hard timeout so it can never wedge the CLI. Default run is still file-only. - New flags: --list print the check registry + exit 0 without running --section run only the named subset (deduped, order-preserved) --fix apply safe reversible remediations (cache-clear only) --yes required together with --fix for write actions --probe opt into live-probe variants - Invalid --section names exit 2 with "Valid: ..." hint via exitWithError. - Unknown check names never silently dropped. - Public helper `listRegisteredTools(server)` added to mcp.ts so doctor can introspect without touching the SDK's private fields directly. --- src/commands/doctor.ts | 259 +++++++++++++++++++++++++++++++--- src/commands/mcp.ts | 12 ++ tests/commands/doctor.test.ts | 118 ++++++++++++++++ 3 files changed, 372 insertions(+), 17 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 13ddf5c..c093283 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -2,13 +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; @@ -364,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') + .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. @@ -375,20 +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(), - checkCatalogSchema(), - checkCache(), - checkQuotaFile(), - await checkClockSkew(), - checkMqtt(), - checkAudit(), - ]; + .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, @@ -397,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' ? '!' : '✗'; @@ -419,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/mcp.ts b/src/commands/mcp.ts index d9be6b3..3a24691 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -963,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/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts index 9178305..3243f21 100644 --- a/tests/commands/doctor.test.ts +++ b/tests/commands/doctor.test.ts @@ -262,4 +262,122 @@ describe('doctor command', () => { 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'); + }); }); From 4a48f075ba39b4521a975f3eb7648ab2d806f663 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:24:30 +0800 Subject: [PATCH 12/16] feat(catalog): statusQueries list powering safetyTier 'read' tier (P11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ReadOnlyQuerySpec type + statusQueries?: ReadOnlyQuerySpec[] on DeviceCatalogEntry. - New deriveStatusQueries(entry) helper: returns explicit statusQueries when set, otherwise synthesises a ReadOnlyQuerySpec per statusFields entry (all keyed to endpoint:'status', safetyTier:'read'). IR entries and entries without statusFields return []. - Field descriptions drawn from a curated STATUS_FIELD_DESCRIPTIONS map that covers the common SwitchBot API v1.1 fields. - capabilities.catalog now surfaces readOnlyQueryCount and adds 'read' to safetyTiersInUse whenever any entry exposes a status query — the enum's 'read' tier is now actually used, not just reserved. - statusFields stays as the source of truth (no duplication) — overrides are possible via explicit statusQueries on specific entries. --- src/commands/capabilities.ts | 12 ++++ src/devices/catalog.ts | 94 +++++++++++++++++++++++++++++ tests/commands/capabilities.test.ts | 7 +++ tests/devices/catalog.test.ts | 40 ++++++++++++ 4 files changed, 153 insertions(+) diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 5bd7c72..3a8b128 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { getEffectiveCatalog, deriveSafetyTier, + deriveStatusQueries, type DeviceCatalogEntry, type SafetyTier, } from '../devices/catalog.js'; @@ -16,10 +17,19 @@ function collectSafetyTiersInUse(entries: DeviceCatalogEntry[]): SafetyTier[] { 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'; @@ -308,6 +318,7 @@ export function registerCapabilitiesCommand(program: Command): void { ), safetyTiersInUse: collectSafetyTiersInUse(catalog), readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, + readOnlyQueryCount: countStatusQueries(catalog), }, }; if (!compact) payload.generatedAt = new Date().toISOString(); @@ -337,6 +348,7 @@ export function registerCapabilitiesCommand(program: Command): void { ), safetyTiersInUse: collectSafetyTiersInUse(filteredCatalog), readOnlyTypeCount: filteredCatalog.filter((e) => e.readOnly).length, + readOnlyQueryCount: countStatusQueries(filteredCatalog), }; payload.usedFilter = { applied: true, typesInCache: [...seen].sort() }; } diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index b5d773e..dd75862 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -93,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[] = [ diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index e0012d5..3f3a6d6 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; diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index b9c82b0..2c34080 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -428,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'); + } + }); + }); }); From 4ec2a63c40c4b8b46ed5483c9884d8beaa8598f2 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:26:36 +0800 Subject: [PATCH 13/16] refactor(batch): rename --plan to --emit-plan, deprecate old flag (P12) Two flags called "plan" with different meanings (batch --plan emits a plan document; `plan` is its own subcommand that runs plan docs) is confusing. Rename the batch flag to --emit-plan, keep --plan accepted for one minor with a deprecation warning on stderr so existing scripts don't break. - New --emit-plan flag (canonical name). - --plan still accepted; prints "[WARN] --plan is deprecated; use --emit-plan. Will be removed in v3.0." to stderr before executing. - Passing both together is a usage error (exit 2). - Help text marks --plan as [DEPRECATED] and updates the Planning section to show --emit-plan. --- src/commands/batch.ts | 22 ++++++++++--- tests/commands/batch.test.ts | 64 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/commands/batch.ts b/src/commands/batch.ts index 50bfde2..446b592 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -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.')); @@ -321,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/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 } }); From 022ec75be251e71b2769d8f78b5bbc908792d310 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:31:01 +0800 Subject: [PATCH 14/16] feat(field-aliases): final Phase 4 sweep (~98% coverage) Add 8 ultra-niche alias groups covering water leak, pressure sensor, motion counter, error codes, and webhook payload fields (buttonName, pressedAt, deviceMac, detectionState). Registry now at ~51 canonical keys covering ~98% of catalog statusFields and webhook event fields. Phase 4 additions: - waterLeakDetect: leak, water - pressure: press, pa - moveCount: movecnt - errorCode: err - buttonName: btn, button - pressedAt: pressed (distinct from pressure.press) - deviceMac: mac - detectionState: detected, detect All existing conflict rules preserved (no 'type'/'state'/'switch'/'on'). Remaining ~2% deferred to user-driven PR per plan. --- src/schema/field-aliases.ts | 10 +++++++ tests/schema/field-aliases.test.ts | 44 ++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/schema/field-aliases.ts b/src/schema/field-aliases.ts index e986192..a79bc33 100644 --- a/src/schema/field-aliases.ts +++ b/src/schema/field-aliases.ts @@ -77,6 +77,16 @@ export const FIELD_ALIASES: Record = { 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'], }; /** diff --git a/tests/schema/field-aliases.test.ts b/tests/schema/field-aliases.test.ts index f04d6ff..c874eb5 100644 --- a/tests/schema/field-aliases.test.ts +++ b/tests/schema/field-aliases.test.ts @@ -8,8 +8,8 @@ import { } from '../../src/schema/field-aliases.js'; describe('FIELD_ALIASES registry', () => { - it('has at least ~43 canonical keys after P1 expansion', () => { - expect(Object.keys(FIELD_ALIASES).length).toBeGreaterThanOrEqual(43); + 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)', () => { @@ -227,6 +227,46 @@ describe('resolveField() — Phase 3 aliases', () => { }); }); +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); From 5690bca81e7958424e43fb1d539f6e90d5e35953 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:34:23 +0800 Subject: [PATCH 15/16] feat(resources): scenes/webhooks/keys metadata catalog (P15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add declarative metadata for non-device resources (scenes, webhooks, keypad keys) so AI agents can discover these surfaces through the same bootstrap path as device commands. - src/devices/resources.ts: SceneSpec, WebhookCatalog (4 endpoints, 15 event specs covering Meter/Presence/Contact/Lock/Plug/Bot/Curtain/ Doorbell/Keypad/ColorBulb/Strip/Sweeper/WaterLeak/Hub/CO2), KeySpec (4 types: permanent/timeLimit/disposable/urgent), constraints. - capabilities: emit RESOURCE_CATALOG under the new 'resources' top-level key alongside 'catalog'. - schema export: same pass-through so the published schema document includes resource metadata. - Tests: 14 new in tests/devices/resources.test.ts (tier validation, event field completeness, key-type coverage) + 1 capabilities test asserting resources presence. MCP tool surface (setup_webhook/query_webhook/create_key/delete_key) is reachable today via send_command + the webhook CLI; dedicated MCP tools deferred — the metadata is already queryable via capabilities. --- src/commands/capabilities.ts | 2 + src/commands/schema.ts | 2 + src/devices/resources.ts | 336 ++++++++++++++++++++++++++++ tests/commands/capabilities.test.ts | 14 ++ tests/devices/resources.test.ts | 120 ++++++++++ 5 files changed, 474 insertions(+) create mode 100644 src/devices/resources.ts create mode 100644 tests/devices/resources.test.ts diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts index 3a8b128..26587d2 100644 --- a/src/commands/capabilities.ts +++ b/src/commands/capabilities.ts @@ -6,6 +6,7 @@ import { 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'; @@ -320,6 +321,7 @@ export function registerCapabilitiesCommand(program: Command): void { readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, readOnlyQueryCount: countStatusQueries(catalog), }, + resources: RESOURCE_CATALOG, }; if (!compact) payload.generatedAt = new Date().toISOString(); diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 31dec81..2801f59 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -9,6 +9,7 @@ import { type DeviceCatalogEntry, type SafetyTier, } from '../devices/catalog.js'; +import { RESOURCE_CATALOG } from '../devices/resources.js'; import { loadCache } from '../devices/cache.js'; interface SchemaEntry { @@ -207,6 +208,7 @@ Examples: const payload: Record = { version: '1.0', types: projected, + resources: RESOURCE_CATALOG, }; if (!options.compact) { payload.generatedAt = new Date().toISOString(); 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/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts index 3f3a6d6..272aeff 100644 --- a/tests/commands/capabilities.test.ts +++ b/tests/commands/capabilities.test.ts @@ -246,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/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), + ); + }); + }); +}); From 28bfc2960b2d7207cf96bcb0e5de8196f64cc315 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Tue, 21 Apr 2026 23:36:06 +0800 Subject: [PATCH 16/16] chore: bump to 2.7.0 + sync lockfile + CHANGELOG (P16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close out the v2.7.0 AI-first maturity release. 15 feature commits landed on this branch (P1–P15): - Field-alias registry expanded from ~10 to ~51 canonical keys - safetyTier 5-tier enum replaces destructive:boolean - Help-JSON contract coverage for 16 commands - MCP tool schema + log + structuredContent polish - Unified error envelope across all commands - Unified events envelope (tail / mqtt-tail) - Streaming JSON header + docs/json-contract.md - Quota records all API attempts (not just successes) - doctor quota headroom / catalog-schema / audit checks - doctor MQTT live-probe + MCP dry-run + --section/--list/--fix - catalog statusQueries powering safetyTier 'read' - batch --plan renamed to --emit-plan (with deprecation warning) - --format=yaml/tsv for all non-streaming commands - FIELD_ALIASES Phase 4 ultra-niche sweep - Resources catalog (scenes/webhooks/keys) exposed via capabilities/schema 1262 tests passing across 60 test files. --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1148f88..8d8be28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.7.0] - 2026-04-21 + +AI-first maturity release. Broader field-alias coverage, richer capability +metadata, and agent-discoverable resource surfaces (scenes, webhooks, keys). + +### Added + +- **Field aliases** — registry expanded from ~10 to ~51 canonical keys (~98% coverage of catalog `statusFields` + webhook payload fields), dispatched through `devices status`, `devices watch`, and `--fields` parsers. Phase 4 sweep adds ultra-niche sensor/webhook aliases: `waterLeakDetect`, `pressure`, `moveCount`, `errorCode`, `buttonName`, `pressedAt`, `deviceMac`, `detectionState`. +- **safetyTier enum (5 tiers)** — catalog commands now carry `safetyTier: 'read' | 'mutation' | 'ir-fire-forget' | 'destructive' | 'maintenance'`; replaces the legacy `destructive: boolean` flag. +- **`DeviceCatalogEntry.statusQueries`** — read-tier catalog entries exposing queryable status fields; derived from existing `statusFields` plus a curated `STATUS_FIELD_DESCRIPTIONS` map. Powers `safetyTier: 'read'` and lights up `capabilities.catalog.readOnlyQueryCount`. +- **`capabilities.resources`** — new top-level `resources` block in `capabilities --json` and `schema export`, exposing scenes (list/execute/describe), webhooks (4 endpoints + 15 event specs + constraints), and keypad keys (4 types: permanent/timeLimit/disposable/urgent). Each endpoint/event declares its safety tier so agents can plan without trial-and-error. +- **Multi-format output** — `--format=yaml` and `--format=tsv` for all non-streaming commands (devices list, scenes list, catalog, etc.); `id` / `markdown` formats preserved. `--json` remains the alias for `--format=json`. +- **doctor upgrades** — new `--section`, `--list`, `--fix`, `--yes`, `--probe` flags; new checks `catalog-schema`, `audit`, `mcp` (dry-run — instantiates MCP server and counts registered tools), plus live MQTT probe (guarded by `--probe`, 5 s timeout). +- **Streaming JSON contract** — every streaming command (watch / events tail / events mqtt-tail) now emits a `{ schemaVersion, stream: true, eventKind, cadence }` header as its first NDJSON line; documented in `docs/json-contract.md`. +- **Events envelope** — unified `{ schemaVersion, t, source, deviceId, topic, type, payload }` shape across `events tail` and `events mqtt-tail`. +- **MCP tool schema completeness** — every tool input schema now carries `.describe()` annotations; new test suite enforces this. +- **Help-JSON contract test** — table-driven coverage for all 16 top-level commands. +- **batch `--emit-plan`** — new canonical flag alias for the deprecated `--plan`. + +### Changed + +- **Error envelope** — all error paths route through `exitWithError()` / `handleError()`; `--json` failure output always carries `schemaVersion` + structured `error` object. +- **Quota accounting** — requests are recorded on attempt (request interceptor) instead of on success, so timeouts / 4xx / 5xx count against daily quota. +- **`--json` vs `--format=json`** — both paths go through the same formatter; `--json` is now documented as the alias. + +### Deprecated + +- `destructive: boolean` on catalog entries — derived from `safetyTier === 'destructive'`. Removed in v3.0. +- `DeviceCatalogEntry.statusFields` — superseded by `statusQueries`. Removed in v3.0. +- `batch --plan` — renamed to `--emit-plan`. Old flag still works but prints a deprecation warning to stderr. Removed in v3.0. +- Events legacy fields `body` / `remote` on `events tail` — superseded by the unified envelope. Removed in v3.0. + +### Reserved + +- `safetyTier: 'maintenance'` — enum value accepted by the type system but no catalog entry uses it today. Reserved for future SwitchBot API endpoints (factoryReset, firmwareUpdate, deepCalibrate). + +### Fixed + +- Quota counter no longer under-counts requests that fail at the transport or server layer. + ## [2.6.4] - 2026-04-21 ### Added diff --git a/package-lock.json b/package-lock.json index ffe1eee..9a1765a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "2.6.1", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "2.6.1", + "version": "2.7.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 0f8275c..f94758e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.6.4", + "version": "2.7.0", "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.", "keywords": [ "switchbot",