From 262eb75113d7b6d05e53683f0fdaed996a33d22a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 15:52:46 +0800 Subject: [PATCH 01/26] feat(catalog): annotate commands with role/destructive/idempotent; enrich describe with capabilities - Extend DeviceCatalogEntry with role, readOnly; extend CommandSpec with idempotent, destructive, exampleParams - Annotate all 36+ catalog entries: turnOn/turnOff idempotent across the board; Smart Lock unlock / Garage Door Opener turnOn+turnOff / Keypad createKey+deleteKey marked destructive; sensor+hub entries readOnly - Add suggestedActions(): picks up to 3 idempotent, non-destructive, non-customize commands with exampleParams when available - describe now surfaces capabilities {role, readOnly, commands, statusFields} with a source tag ('catalog' | 'live' | 'catalog+live') and suggestedActions in --json output - Add --live opt-in flag on describe to merge /v1.1/devices/{id}/status into capabilities.liveStatus (IR devices no-op; /status failures captured, not fatal) - Human-readable describe distinguishes physical vs IR fallback; physical missing from catalog suggests 'devices status', IR suggests '--type customize' - describe output visually flags destructive commands with [!destructive] and a trailing warning; shows Role/ReadOnly when present Tests: 329 total (+27 new). New tests/devices/catalog.test.ts covers schema integrity, command annotations, role assignments, and suggestedActions behaviour; describe tests cover capabilities/source, destructive surfacing, ReadOnly display, and --live success / IR no-op / error-recovery paths. --- src/commands/devices.ts | 101 ++++++++-- src/devices/catalog.ts | 335 ++++++++++++++++++++++++--------- tests/commands/devices.test.ts | 174 ++++++++++++++++- tests/devices/catalog.test.ts | 186 ++++++++++++++++++ 4 files changed, 687 insertions(+), 109 deletions(-) create mode 100644 tests/devices/catalog.test.ts diff --git a/src/commands/devices.ts b/src/commands/devices.ts index d4dba42..1c16c5d 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import { createClient } from '../api/client.js'; import { printTable, printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js'; -import { DEVICE_CATALOG, findCatalogEntry, DeviceCatalogEntry } from '../devices/catalog.js'; +import { DEVICE_CATALOG, findCatalogEntry, DeviceCatalogEntry, suggestedActions } from '../devices/catalog.js'; import { getCachedDevice, updateCacheFromDeviceList } from '../devices/cache.js'; interface Device { @@ -343,18 +343,36 @@ Examples: .command('describe') .description('Describe a device by ID: metadata + supported commands + status fields (1 API call)') .argument('', 'Target device ID from "devices list"') + .option('--live', 'Also fetch live status values and merge them into capabilities (costs 1 extra API call)') .addHelpText('after', ` -Makes a single GET /v1.1/devices call to look up the device's type, then -prints its metadata alongside the matching catalog entry (supported -commands + parameter formats + status field names). - -Does NOT fetch live status values. Use 'switchbot devices status ' for that. +Makes a GET /v1.1/devices call to look up the device's type, then prints its +metadata alongside the matching catalog entry (supported commands + parameter +formats + status field names). With --live, makes a second call to fetch the +current status values and merges them into the output. + +JSON output shape (--json): + { + device: , + controlType: , + catalog: , + capabilities: { + role: , + readOnly: , + commands: [{command, parameter, description, idempotent?, destructive?, exampleParams?}], + statusFields: [], + liveStatus: + }, + source: "catalog" | "live" | "catalog+live", + suggestedActions: [{command, parameter?, description}] + } Examples: $ switchbot devices describe ABC123DEF456 + $ switchbot devices describe ABC123DEF456 --live $ switchbot devices describe ABC123DEF456 --json + $ switchbot devices describe --json | jq '.capabilities.commands[] | select(.destructive)' `) - .action(async (deviceId: string) => { + .action(async (deviceId: string, options: { live?: boolean }) => { try { const client = createClient(); const res = await client.get<{ body: DeviceListBody }>('/v1.1/devices'); @@ -375,11 +393,43 @@ Examples: const match = typeName ? findCatalogEntry(typeName) : null; const catalogEntry = !match || Array.isArray(match) ? null : match; + // Optionally fetch live status for physical devices. IR remotes have no + // status channel, so --live silently does nothing for them. + let liveStatus: Record | undefined; + if (options.live && physical) { + try { + const statusRes = await client.get<{ body: Record }>( + `/v1.1/devices/${deviceId}/status` + ); + liveStatus = statusRes.data.body; + } catch (err) { + // Don't fail the whole describe when status fails; note it instead. + liveStatus = { error: err instanceof Error ? err.message : String(err) }; + } + } + + const source: 'catalog' | 'live' | 'catalog+live' = catalogEntry + ? (liveStatus ? 'catalog+live' : 'catalog') + : (liveStatus ? 'live' : 'catalog'); + if (isJsonMode()) { + const capabilities = catalogEntry + ? { + role: catalogEntry.role ?? null, + readOnly: catalogEntry.readOnly ?? false, + commands: catalogEntry.commands, + statusFields: catalogEntry.statusFields ?? [], + ...(liveStatus !== undefined ? { liveStatus } : {}), + } + : (liveStatus !== undefined ? { liveStatus } : null); + printJson({ device: physical ?? ir, controlType: (physical?.controlType ?? ir?.controlType) ?? null, catalog: catalogEntry, + capabilities, + source, + suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [], }); return; } @@ -412,11 +462,28 @@ Examples: console.log(''); if (!catalogEntry) { + // When the deviceType isn't in the catalog, distinguish physical vs + // IR: physical devices should use 'devices status'; IR remotes + // expose only user-defined custom buttons. + const isPhysical = Boolean(physical); console.log(`(Type "${typeName}" is not in the built-in catalog — no command reference available.)`); - console.log(`Send custom IR buttons with: switchbot devices command ${deviceId} "" --type customize`); + if (isPhysical) { + console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`); + } else { + console.log(`Send custom IR buttons with: switchbot devices command ${deviceId} "" --type customize`); + } + if (liveStatus) { + console.log('\nLive status:'); + printKeyValue(liveStatus); + } return; } renderCatalogEntry(catalogEntry); + + if (liveStatus) { + console.log('\nLive status:'); + printKeyValue(liveStatus); + } } catch (error) { handleError(error); } @@ -487,6 +554,8 @@ function validateCommandAgainstCache( function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log(`Type: ${entry.type}`); console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`); + if (entry.role) console.log(`Role: ${entry.role}`); + if (entry.readOnly) console.log(`ReadOnly: yes (status-only device, no control commands)`); if (entry.aliases && entry.aliases.length > 0) { console.log(`Aliases: ${entry.aliases.join(', ')}`); } @@ -495,12 +564,18 @@ function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log('\nCommands: (none — status-only device)'); } else { console.log('\nCommands:'); - const rows = entry.commands.map((c) => [ - c.commandType === 'customize' ? `${c.command} [customize]` : c.command, - c.parameter, - c.description, - ]); + const rows = entry.commands.map((c) => { + const flags: string[] = []; + if (c.commandType === 'customize') flags.push('customize'); + if (c.destructive) flags.push('!destructive'); + const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command; + return [label, c.parameter, c.description]; + }); printTable(['command', 'parameter', 'description'], rows); + const hasDestructive = entry.commands.some((c) => c.destructive); + if (hasDestructive) { + console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.'); + } } if (entry.statusFields && entry.statusFields.length > 0) { diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 60fd8d4..0bda50f 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -2,6 +2,18 @@ * Static catalog of SwitchBot device types, control commands and status fields. * Sourced from https://github.com/OpenWonderLabs/SwitchBotAPI — keep in sync * when the upstream API adds new device types. + * + * Field conventions: + * - 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. + * - 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'. */ export interface CommandSpec { @@ -9,28 +21,49 @@ export interface CommandSpec { parameter: string; description: string; commandType?: 'command' | 'customize'; + idempotent?: boolean; + destructive?: boolean; + exampleParams?: string[]; } +/** Coarse functional role — helpful for cross-type selection in agents. */ +export type DeviceRole = + | 'lighting' + | 'climate' + | 'security' + | 'media' + | 'sensor' + | 'cleaning' + | 'curtain' + | 'fan' + | 'power' + | 'hub' + | 'other'; + export interface DeviceCatalogEntry { type: string; category: 'physical' | 'ir'; aliases?: string[]; commands: CommandSpec[]; statusFields?: string[]; + role?: DeviceRole; + readOnly?: boolean; } +// ---- Command fragments (reused across entries) ------------------------- + const onOff: CommandSpec[] = [ - { command: 'turnOn', parameter: '—', description: 'Power on' }, - { command: 'turnOff', parameter: '—', description: 'Power off' }, + { command: 'turnOn', parameter: '—', description: 'Power on', idempotent: true }, + { command: 'turnOff', parameter: '—', description: 'Power off', idempotent: true }, ]; const onOffToggle: CommandSpec[] = [ ...onOff, - { command: 'toggle', parameter: '—', description: 'Toggle power' }, + { command: 'toggle', parameter: '—', description: 'Toggle power', idempotent: false }, ]; const lightControls: CommandSpec[] = [ - { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage' }, - { command: 'setColor', parameter: 'R:G:B (0-255 each)', description: 'Set RGB color, e.g. "255:0:0"' }, - { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)' }, + { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage', idempotent: true, exampleParams: ['50', '80'] }, + { command: 'setColor', parameter: 'R:G:B (0-255 each)', description: 'Set RGB color, e.g. "255:0:0"', idempotent: true, exampleParams: ['255:0:0', '255:255:255'] }, + { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)', idempotent: true, exampleParams: ['2700', '4000', '6500'] }, ]; export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ @@ -38,63 +71,70 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Bot', category: 'physical', + role: 'other', commands: [ ...onOff, - { command: 'press', parameter: '—', description: 'Press the button (momentary)' }, + { command: 'press', parameter: '—', description: 'Press the button (momentary)', idempotent: false }, ], statusFields: ['power', 'battery', 'deviceMode', 'version'], }, { type: 'Curtain', category: 'physical', + role: 'curtain', aliases: ['Curtain3', 'Curtain 3'], commands: [ ...onOff, - { command: 'pause', parameter: '—', description: 'Stop movement' }, - { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position' }, - { command: 'setPosition', parameter: 'index,mode,position (e.g. "0,ff,80")', description: 'Multi-arg form: mode=0 Performance | 1 Silent | ff default' }, + { command: 'pause', parameter: '—', description: 'Stop movement', idempotent: true }, + { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position', idempotent: true, exampleParams: ['0', '50', '100'] }, + { command: 'setPosition', parameter: 'index,mode,position (e.g. "0,ff,80")', description: 'Multi-arg form: mode=0 Performance | 1 Silent | ff default', idempotent: true, exampleParams: ['0,ff,50'] }, ], statusFields: ['calibrate', 'group', 'moving', 'slidePosition', 'battery', 'version'], }, { type: 'Smart Lock', category: 'physical', + role: 'security', aliases: ['Smart Lock Pro'], commands: [ - { command: 'lock', parameter: '—', description: 'Lock the door' }, - { command: 'unlock', parameter: '—', description: 'Unlock the door' }, - { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt' }, + { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true }, + { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt', idempotent: true }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], }, { type: 'Smart Lock Lite', category: 'physical', + role: 'security', commands: [ - { command: 'lock', parameter: '—', description: 'Lock the door' }, - { command: 'unlock', parameter: '—', description: 'Unlock the door' }, + { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], }, { type: 'Smart Lock Ultra', category: 'physical', + role: 'security', commands: [ - { command: 'lock', parameter: '—', description: 'Lock the door' }, - { command: 'unlock', parameter: '—', description: 'Unlock the door' }, - { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt' }, + { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true }, + { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], }, { type: 'Plug', category: 'physical', + role: 'power', commands: onOffToggle, statusFields: ['power', 'version'], }, { type: 'Plug Mini (US)', category: 'physical', + role: 'power', aliases: ['Plug Mini (JP)'], commands: onOffToggle, statusFields: ['voltage', 'weight', 'electricityOfDay', 'electricCurrent', 'power', 'version'], @@ -102,195 +142,218 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Relay Switch 1', category: 'physical', + role: 'power', aliases: ['Relay Switch 1PM'], commands: [ ...onOffToggle, - { command: 'setMode', parameter: '0=toggle | 1=edge | 2=detached | 3=momentary', description: 'Switch operating mode' }, + { command: 'setMode', parameter: '0=toggle | 1=edge | 2=detached | 3=momentary', description: 'Switch operating mode', idempotent: true, exampleParams: ['0', '1', '2', '3'] }, ], statusFields: ['switchStatus', 'voltage', 'version', 'useTime', 'electricCurrent', 'power', 'usedElectricity'], }, { type: 'Relay Switch 2PM', category: 'physical', + role: 'power', commands: [ - { command: 'turnOn', parameter: '1 | 2 (channel)', description: 'Turn on channel 1 or 2' }, - { command: 'turnOff', parameter: '1 | 2 (channel)', description: 'Turn off channel 1 or 2' }, - { command: 'toggle', parameter: '1 | 2 (channel)', description: 'Toggle channel 1 or 2' }, - { command: 'setMode', parameter: '";" e.g. "1;0"', description: 'Per-channel mode (see Relay Switch 1 modes)' }, - { command: 'setPosition', parameter: '0-100 (roller percentage)', description: 'Roller-shade-pair mode only' }, + { command: 'turnOn', parameter: '1 | 2 (channel)', description: 'Turn on channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, + { command: 'turnOff', parameter: '1 | 2 (channel)', description: 'Turn off channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, + { command: 'toggle', parameter: '1 | 2 (channel)', description: 'Toggle channel 1 or 2', idempotent: false, exampleParams: ['1', '2'] }, + { command: 'setMode', parameter: '";" e.g. "1;0"', description: 'Per-channel mode (see Relay Switch 1 modes)', idempotent: true, exampleParams: ['1;0', '2;3'] }, + { command: 'setPosition', parameter: '0-100 (roller percentage)', description: 'Roller-shade-pair mode only', idempotent: true, exampleParams: ['0', '50', '100'] }, ], statusFields: ['switch1Status', 'switch2Status', 'voltage', 'electricCurrent', 'power', 'usedElectricity'], }, { type: 'Humidifier', category: 'physical', + role: 'climate', commands: [ ...onOff, - { command: 'setMode', parameter: 'auto | 101 (34%) | 102 (67%) | 103 (100%) | 0-100', description: 'Set preset or target humidity' }, + { command: 'setMode', parameter: 'auto | 101 (34%) | 102 (67%) | 103 (100%) | 0-100', description: 'Set preset or target humidity', idempotent: true, exampleParams: ['auto', '101', '50'] }, ], statusFields: ['power', 'humidity', 'temperature', 'nebulizationEfficiency', 'auto', 'childLock', 'sound', 'lackWater'], }, { type: 'Humidifier2', category: 'physical', + role: 'climate', aliases: ['Evaporative Humidifier'], commands: [ ...onOff, - { command: 'setMode', parameter: '\'{"mode":1-8,"targetHumidify":0-100}\'', description: 'mode: 1=lv4 2=lv3 3=lv2 4=lv1 5=humidity 6=sleep 7=auto 8=drying' }, - { command: 'setChildLock', parameter: 'true | false', description: 'Enable or disable child lock' }, + { command: 'setMode', parameter: '\'{"mode":1-8,"targetHumidify":0-100}\'', description: 'mode: 1=lv4 2=lv3 3=lv2 4=lv1 5=humidity 6=sleep 7=auto 8=drying', idempotent: true, exampleParams: ['{"mode":7,"targetHumidify":50}'] }, + { command: 'setChildLock', parameter: 'true | false', description: 'Enable or disable child lock', idempotent: true, exampleParams: ['true', 'false'] }, ], statusFields: ['power', 'humidity', 'temperature', 'mode', 'childLock', 'filterElement'], }, { type: 'Air Purifier VOC', category: 'physical', + role: 'climate', aliases: ['Air Purifier PM2.5', 'Air Purifier Table VOC', 'Air Purifier Table PM2.5'], commands: [ ...onOff, - { command: 'setMode', parameter: '\'{"mode":1-4,"fanGear":1-3}\'', description: 'mode: 1=normal 2=auto 3=sleep 4=pet; fanGear only when mode=1' }, - { command: 'setChildLock', parameter: '0 | 1', description: 'Disable / enable child lock' }, + { command: 'setMode', parameter: '\'{"mode":1-4,"fanGear":1-3}\'', description: 'mode: 1=normal 2=auto 3=sleep 4=pet; fanGear only when mode=1', idempotent: true, exampleParams: ['{"mode":2}', '{"mode":1,"fanGear":2}'] }, + { command: 'setChildLock', parameter: '0 | 1', description: 'Disable / enable child lock', idempotent: true, exampleParams: ['0', '1'] }, ], statusFields: ['power', 'mode', 'childLock', 'filterElement'], }, { type: 'Color Bulb', category: 'physical', + role: 'lighting', commands: [...onOffToggle, ...lightControls], statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'], }, { type: 'Strip Light', category: 'physical', - commands: [...onOffToggle, ...lightControls.slice(0, 2)], - statusFields: ['power', 'brightness', 'color', 'version'], + role: 'lighting', + aliases: ['Strip Light 3'], + commands: [...onOffToggle, ...lightControls], + statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'], }, { type: 'Ceiling Light', category: 'physical', + role: 'lighting', aliases: ['Ceiling Light Pro'], commands: [ ...onOffToggle, - { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage' }, - { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)' }, + { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage', idempotent: true, exampleParams: ['50', '80'] }, + { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)', idempotent: true, exampleParams: ['2700', '4000', '6500'] }, ], statusFields: ['power', 'brightness', 'colorTemperature', 'version'], }, { type: 'Smart Radiator Thermostat', category: 'physical', + role: 'climate', commands: [ ...onOff, - { command: 'setMode', parameter: '0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat', description: 'Operating mode' }, - { command: 'setManualModeTemperature', parameter: '5-30 (°C)', description: 'Target temperature in manual mode' }, + { command: 'setMode', parameter: '0=schedule 1=manual 2=off 3=eco 4=comfort 5=quickHeat', description: 'Operating mode', idempotent: true, exampleParams: ['1', '3'] }, + { command: 'setManualModeTemperature', parameter: '5-30 (°C)', description: 'Target temperature in manual mode', idempotent: true, exampleParams: ['20', '22'] }, ], statusFields: ['power', 'temperature', 'humidity', 'battery', 'version', 'mode', 'targetTemperature'], }, { type: 'Robot Vacuum Cleaner S1', category: 'physical', + role: 'cleaning', aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'], commands: [ - { command: 'start', parameter: '—', description: 'Start cleaning' }, - { command: 'stop', parameter: '—', description: 'Stop cleaning' }, - { command: 'dock', parameter: '—', description: 'Return to dock' }, - { command: 'PowLevel', parameter: '0-3', description: '0=Quiet 1=Standard 2=Strong 3=Max' }, + { command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true }, + { command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true }, + { command: 'dock', parameter: '—', description: 'Return to dock', idempotent: true }, + { command: 'PowLevel', parameter: '0-3', description: '0=Quiet 1=Standard 2=Strong 3=Max', idempotent: true, exampleParams: ['0', '1', '2', '3'] }, ], statusFields: ['workingStatus', 'onlineStatus', 'battery', 'version'], }, { type: 'K10+ Pro Combo', category: 'physical', + role: 'cleaning', aliases: ['K20+ Pro'], commands: [ - { command: 'startClean', parameter: '\'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}\'', description: 'Begin a cleaning session' }, - { command: 'pause', parameter: '—', description: 'Pause cleaning' }, - { command: 'dock', parameter: '—', description: 'Return to dock' }, - { command: 'setVolume', parameter: '0-100', description: 'Set voice volume' }, - { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run' }, + { command: 'startClean', parameter: '\'{"action":"sweep"|"mop","param":{"fanLevel":1-4,"times":1-2639999}}\'', description: 'Begin a cleaning session', idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"times":1}}'] }, + { command: 'pause', parameter: '—', description: 'Pause cleaning', idempotent: true }, + { command: 'dock', parameter: '—', description: 'Return to dock', idempotent: true }, + { command: 'setVolume', parameter: '0-100', description: 'Set voice volume', idempotent: true, exampleParams: ['0', '50', '100'] }, + { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run', idempotent: true, exampleParams: ['{"fanLevel":3,"waterLevel":1,"times":1}'] }, ], statusFields: ['workingStatus', 'onlineStatus', 'battery', 'taskType'], }, { type: 'Floor Cleaning Robot S10', category: 'physical', + role: 'cleaning', aliases: ['Robot Vacuum Cleaner S10', 'Robot Vacuum Cleaner S20'], commands: [ - { command: 'startClean', parameter: '\'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}\'', description: 'Begin a cleaning session' }, - { command: 'pause', parameter: '—', description: 'Pause cleaning' }, - { command: 'dock', parameter: '—', description: 'Return to dock' }, - { command: 'addWaterForHumi', parameter: '—', description: 'Refill the humidifier water tank' }, - { command: 'selfClean', parameter: '1 | 2 | 3', description: '1=wash mop | 2=dry | 3=terminate self-clean' }, - { command: 'setVolume', parameter: '0-100', description: 'Set voice volume' }, - { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run' }, + { command: 'startClean', parameter: '\'{"action":"sweep"|"sweep_mop","param":{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}}\'', description: 'Begin a cleaning session', idempotent: false, exampleParams: ['{"action":"sweep","param":{"fanLevel":2,"waterLevel":1,"times":1}}'] }, + { command: 'pause', parameter: '—', description: 'Pause cleaning', idempotent: true }, + { command: 'dock', parameter: '—', description: 'Return to dock', idempotent: true }, + { command: 'addWaterForHumi', parameter: '—', description: 'Refill the humidifier water tank', idempotent: false }, + { command: 'selfClean', parameter: '1 | 2 | 3', description: '1=wash mop | 2=dry | 3=terminate self-clean', idempotent: false, exampleParams: ['1', '2', '3'] }, + { command: 'setVolume', parameter: '0-100', description: 'Set voice volume', idempotent: true, exampleParams: ['0', '50', '100'] }, + { command: 'changeParam', parameter: '\'{"fanLevel":1-4,"waterLevel":1-2,"times":1-2639999}\'', description: 'Change parameters mid-run', idempotent: true, exampleParams: ['{"fanLevel":3,"waterLevel":1,"times":1}'] }, ], statusFields: ['workingStatus', 'onlineStatus', 'battery', 'taskType'], }, { type: 'Battery Circulator Fan', category: 'physical', + role: 'fan', aliases: ['Circulator Fan'], commands: [ ...onOffToggle, - { command: 'setNightLightMode', parameter: 'off | 1 | 2', description: 'Night-light mode' }, - { command: 'setWindMode', parameter: 'direct | natural | sleep | baby', description: 'Wind mode' }, - { command: 'setWindSpeed', parameter: '1-100', description: 'Fan speed' }, - { command: 'closeDelay', parameter: 'seconds', description: 'Auto-off timer in seconds' }, + { command: 'setNightLightMode', parameter: 'off | 1 | 2', description: 'Night-light mode', idempotent: true, exampleParams: ['off', '1', '2'] }, + { command: 'setWindMode', parameter: 'direct | natural | sleep | baby', description: 'Wind mode', idempotent: true, exampleParams: ['natural', 'sleep'] }, + { command: 'setWindSpeed', parameter: '1-100', description: 'Fan speed', idempotent: true, exampleParams: ['50', '100'] }, + { command: 'closeDelay', parameter: 'seconds', description: 'Auto-off timer in seconds', idempotent: true, exampleParams: ['1800', '3600'] }, ], statusFields: ['mode', 'version', 'battery', 'power', 'nightStatus', 'oscillation', 'verticalOscillation', 'chargingStatus', 'fanSpeed'], }, { type: 'Blind Tilt', category: 'physical', + role: 'curtain', commands: [ ...onOff, - { command: 'setPosition', parameter: '";" (up|down; 0,2,...,100)', description: 'Tilt direction + angle (0=closed, 100=open)' }, - { command: 'fullyOpen', parameter: '—', description: 'Open fully' }, - { command: 'closeUp', parameter: '—', description: 'Close up' }, - { command: 'closeDown', parameter: '—', description: 'Close down' }, + { command: 'setPosition', parameter: '";" (up|down; 0,2,...,100)', description: 'Tilt direction + angle (0=closed, 100=open)', idempotent: true, exampleParams: ['up;50', 'down;80'] }, + { command: 'fullyOpen', parameter: '—', description: 'Open fully', idempotent: true }, + { command: 'closeUp', parameter: '—', description: 'Close up', idempotent: true }, + { command: 'closeDown', parameter: '—', description: 'Close down', idempotent: true }, ], statusFields: ['version', 'calibrate', 'group', 'moving', 'direction', 'slidePosition', 'battery'], }, { type: 'Roller Shade', category: 'physical', + role: 'curtain', commands: [ ...onOff, - { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position' }, + { command: 'setPosition', parameter: '0-100 (0=open, 100=closed)', description: 'Move to a position', idempotent: true, exampleParams: ['0', '50', '100'] }, ], statusFields: ['slidePosition', 'battery', 'version', 'moving'], }, { type: 'Garage Door Opener', category: 'physical', - commands: onOff, + role: 'security', + commands: [ + { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, destructive: true }, + { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, destructive: true }, + ], statusFields: ['switchStatus', 'version', 'online'], }, { type: 'Video Doorbell', category: 'physical', + role: 'security', commands: [ - { command: 'enableMotionDetection', parameter: '—', description: 'Enable motion detection' }, - { command: 'disableMotionDetection', parameter: '—', description: 'Disable motion detection' }, + { command: 'enableMotionDetection', parameter: '—', description: 'Enable motion detection', idempotent: true }, + { command: 'disableMotionDetection', parameter: '—', description: 'Disable motion detection', idempotent: true }, ], statusFields: ['battery', 'version'], }, { type: 'Keypad', category: 'physical', + 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)' }, - { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode (async; result via webhook)' }, + { 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 }, + { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, destructive: true }, ], statusFields: ['version'], }, { type: 'Candle Warmer Lamp', category: 'physical', + role: 'lighting', commands: [ ...onOffToggle, - { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage' }, - { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)' }, + { command: 'setBrightness', parameter: '1-100', description: 'Set brightness percentage', idempotent: true, exampleParams: ['50', '80'] }, + { command: 'setColorTemperature', parameter: '2700-6500', description: 'Set color temperature (Kelvin)', idempotent: true, exampleParams: ['2700', '4000'] }, ], statusFields: ['power', 'brightness', 'colorTemperature', 'version'], }, @@ -298,6 +361,8 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Meter', category: 'physical', + role: 'sensor', + readOnly: true, aliases: ['Meter Plus', 'MeterPro', 'MeterPro(CO2)', 'WoIOSensor', 'Hub 2'], commands: [], statusFields: ['temperature', 'humidity', 'CO2', 'battery', 'version'], @@ -305,86 +370,148 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Motion Sensor', category: 'physical', + role: 'sensor', + readOnly: true, commands: [], statusFields: ['battery', 'version', 'moveDetected', 'brightness', 'openState'], }, { type: 'Contact Sensor', category: 'physical', + role: 'sensor', + readOnly: true, commands: [], statusFields: ['battery', 'version', 'moveDetected', 'openState', 'brightness'], }, { type: 'Water Leak Detector', category: 'physical', + role: 'sensor', + readOnly: true, commands: [], statusFields: ['battery', 'version', 'status'], }, + // Status-only hub-class devices (no control commands) + { + type: 'Hub Mini', + category: 'physical', + role: 'hub', + readOnly: true, + aliases: ['Hub Mini2'], + commands: [], + statusFields: ['version'], + }, + { + type: 'Hub 3', + category: 'physical', + role: 'hub', + readOnly: true, + commands: [], + statusFields: ['version', 'temperature', 'humidity', 'lightLevel'], + }, + { + type: 'AI Hub', + category: 'physical', + role: 'hub', + readOnly: true, + commands: [], + statusFields: ['version'], + }, + { + type: 'Home Climate Panel', + category: 'physical', + role: 'climate', + readOnly: true, + commands: [], + statusFields: ['temperature', 'humidity', 'version'], + }, + { + type: 'Wallet Finder Card', + category: 'physical', + role: 'sensor', + readOnly: true, + commands: [], + statusFields: ['battery', 'version'], + }, + { + type: 'Outdoor Spotlight Cam', + category: 'physical', + role: 'security', + readOnly: true, + commands: [], + statusFields: ['battery', 'version'], + }, // ---------- Virtual IR remotes ---------- { type: 'Air Conditioner', category: 'ir', + role: 'climate', commands: [ ...onOff, - { command: 'setAll', parameter: '",,,"', description: 'mode: 1=auto 2=cool 3=dry 4=fan 5=heat; fan: 1=auto 2=low 3=mid 4=high' }, + { command: 'setAll', parameter: '",,,"', description: 'mode: 1=auto 2=cool 3=dry 4=fan 5=heat; fan: 1=auto 2=low 3=mid 4=high', idempotent: true, exampleParams: ['26,2,3,on', '22,5,2,on'] }, ], }, { type: 'TV', category: 'ir', + role: 'media', aliases: ['IPTV', 'Streamer', 'Set Top Box'], commands: [ ...onOff, - { command: 'SetChannel', parameter: '1-999 (channel number)', description: 'Switch to a specific channel' }, - { command: 'volumeAdd', parameter: '—', description: 'Volume up' }, - { command: 'volumeSub', parameter: '—', description: 'Volume down' }, - { command: 'channelAdd', parameter: '—', description: 'Channel up' }, - { command: 'channelSub', parameter: '—', description: 'Channel down' }, + { command: 'SetChannel', parameter: '1-999 (channel number)', description: 'Switch to a specific channel', idempotent: true, exampleParams: ['1', '15'] }, + { command: 'volumeAdd', parameter: '—', description: 'Volume up', idempotent: false }, + { command: 'volumeSub', parameter: '—', description: 'Volume down', idempotent: false }, + { command: 'channelAdd', parameter: '—', description: 'Channel up', idempotent: false }, + { command: 'channelSub', parameter: '—', description: 'Channel down', idempotent: false }, ], }, { type: 'DVD', category: 'ir', + role: 'media', aliases: ['Speaker'], commands: [ ...onOff, - { command: 'setMute', parameter: '—', description: 'Toggle mute' }, - { command: 'FastForward', parameter: '—', description: 'Fast forward' }, - { command: 'Rewind', parameter: '—', description: 'Rewind' }, - { command: 'Next', parameter: '—', description: 'Next track' }, - { command: 'Previous', parameter: '—', description: 'Previous track' }, - { command: 'Pause', parameter: '—', description: 'Pause' }, - { command: 'Play', parameter: '—', description: 'Play' }, - { command: 'Stop', parameter: '—', description: 'Stop' }, - { command: 'volumeAdd', parameter: '—', description: 'Volume up' }, - { command: 'volumeSub', parameter: '—', description: 'Volume down' }, + { command: 'setMute', parameter: '—', description: 'Toggle mute', idempotent: false }, + { command: 'FastForward', parameter: '—', description: 'Fast forward', idempotent: false }, + { command: 'Rewind', parameter: '—', description: 'Rewind', idempotent: false }, + { command: 'Next', parameter: '—', description: 'Next track', idempotent: false }, + { command: 'Previous', parameter: '—', description: 'Previous track', idempotent: false }, + { command: 'Pause', parameter: '—', description: 'Pause', idempotent: true }, + { command: 'Play', parameter: '—', description: 'Play', idempotent: true }, + { command: 'Stop', parameter: '—', description: 'Stop', idempotent: true }, + { command: 'volumeAdd', parameter: '—', description: 'Volume up', idempotent: false }, + { command: 'volumeSub', parameter: '—', description: 'Volume down', idempotent: false }, ], }, { type: 'Fan', category: 'ir', + role: 'fan', commands: [ ...onOff, - { command: 'swing', parameter: '—', description: 'Toggle swing' }, - { command: 'timer', parameter: '—', description: 'Toggle timer' }, - { command: 'lowSpeed', parameter: '—', description: 'Low speed' }, - { command: 'middleSpeed', parameter: '—', description: 'Medium speed' }, - { command: 'highSpeed', parameter: '—', description: 'High speed' }, + { command: 'swing', parameter: '—', description: 'Toggle swing', idempotent: false }, + { command: 'timer', parameter: '—', description: 'Toggle timer', idempotent: false }, + { command: 'lowSpeed', parameter: '—', description: 'Low speed', idempotent: true }, + { command: 'middleSpeed', parameter: '—', description: 'Medium speed', idempotent: true }, + { command: 'highSpeed', parameter: '—', description: 'High speed', idempotent: true }, ], }, { type: 'Light', category: 'ir', + role: 'lighting', commands: [ ...onOff, - { command: 'brightnessUp', parameter: '—', description: 'Brightness up' }, - { command: 'brightnessDown', parameter: '—', description: 'Brightness down' }, + { command: 'brightnessUp', parameter: '—', description: 'Brightness up', idempotent: false }, + { command: 'brightnessDown', parameter: '—', description: 'Brightness down', idempotent: false }, ], }, { type: 'Others', category: 'ir', + role: 'other', commands: [ { command: '', parameter: '—', description: 'User-defined custom IR button (requires --type customize)', commandType: 'customize' }, ], @@ -410,3 +537,31 @@ export function findCatalogEntry(query: string): DeviceCatalogEntry | DeviceCata if (matches.length === 1) return matches[0]; return matches; } + +/** + * 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 + * at concrete next steps. + */ +export function suggestedActions(entry: DeviceCatalogEntry): Array<{ + command: string; + parameter?: string; + description: string; +}> { + const safe = entry.commands.filter( + (c) => c.idempotent === true && !c.destructive && c.commandType !== 'customize' + ); + const picks: CommandSpec[] = []; + const seen = new Set(); + for (const c of safe) { + if (seen.has(c.command)) continue; + seen.add(c.command); + picks.push(c); + if (picks.length >= 3) break; + } + return picks.map((c) => ({ + command: c.command, + parameter: c.exampleParams?.[0], + description: c.description, + })); +} diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index c513a8a..9ff1aab 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -1151,21 +1151,41 @@ describe('devices command', () => { expect(out).toContain('Living Room'); }); - it('shows metadata + warning when the deviceType is not in the catalog', async () => { + it('shows metadata + status hint when the physical deviceType is not in the catalog', async () => { const body = { deviceList: [{ - deviceId: 'HUB-X', - deviceName: 'Gateway', - deviceType: 'Hub Mini2', + deviceId: 'FB-X', + deviceName: 'Fingerbot', + deviceType: 'Fingerbot Plus', hubDeviceId: '', enableCloudService: true, }], infraredRemoteList: [], }; apiMock.__instance.get.mockResolvedValue({ data: { body } }); - const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'HUB-X']); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'FB-X']); const out = res.stdout.join('\n'); - expect(out).toContain('Hub Mini2'); + expect(out).toContain('Fingerbot Plus'); + expect(out).toContain('not in the built-in catalog'); + // Physical unknown → recommend 'devices status', not --type customize. + expect(out).toContain('switchbot devices status FB-X'); + expect(out).not.toContain('--type customize'); + }); + + it('shows metadata + customize hint when an IR remoteType is not in the catalog', async () => { + const body = { + deviceList: [], + infraredRemoteList: [{ + deviceId: 'IR-ODD', + deviceName: 'Game Console', + remoteType: 'UnknownRemote', + hubDeviceId: 'HUB-1', + }], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body } }); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'IR-ODD']); + const out = res.stdout.join('\n'); + expect(out).toContain('UnknownRemote'); expect(out).toContain('not in the built-in catalog'); expect(out).toContain('--type customize'); }); @@ -1198,6 +1218,148 @@ describe('devices command', () => { expect(parsed).not.toHaveProperty('category'); }); + it('--json includes capabilities, source=catalog, and suggestedActions', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, [ + 'devices', + 'describe', + 'BLE-001', + '--json', + ]); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.source).toBe('catalog'); + expect(parsed.capabilities).toBeDefined(); + expect(parsed.capabilities.role).toBe('other'); + expect(parsed.capabilities.readOnly).toBe(false); + expect(Array.isArray(parsed.capabilities.commands)).toBe(true); + expect(parsed.capabilities.statusFields).toContain('battery'); + expect(Array.isArray(parsed.suggestedActions)).toBe(true); + // turnOn is the first idempotent pick for a Bot + expect(parsed.suggestedActions[0].command).toBe('turnOn'); + }); + + it('--json for a Smart Lock surfaces destructive flag on unlock', async () => { + const lockBody = { + deviceList: [{ + deviceId: 'LOCK-1', + deviceName: 'Front Door', + deviceType: 'Smart Lock', + hubDeviceId: 'HUB-1', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body: lockBody } }); + const res = await runCli(registerDevicesCommand, [ + 'devices', + 'describe', + 'LOCK-1', + '--json', + ]); + const parsed = JSON.parse(res.stdout.join('\n')); + const unlock = parsed.capabilities.commands.find( + (c: { command: string }) => c.command === 'unlock' + ); + expect(unlock).toBeDefined(); + expect(unlock.destructive).toBe(true); + expect(unlock.idempotent).toBe(true); + // suggestedActions must NOT include the destructive unlock + expect( + parsed.suggestedActions.find((a: { command: string }) => a.command === 'unlock') + ).toBeUndefined(); + }); + + it('human output marks destructive commands in the command table', async () => { + const lockBody = { + deviceList: [{ + deviceId: 'LOCK-1', + deviceName: 'Front Door', + deviceType: 'Smart Lock', + hubDeviceId: 'HUB-1', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body: lockBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'LOCK-1']); + const out = res.stdout.join('\n'); + expect(out).toContain('Role:'); + expect(out).toContain('security'); + // The unlock row should carry the destructive badge + const unlockLine = out.split('\n').find((l) => l.includes('unlock')); + expect(unlockLine).toContain('!destructive'); + expect(out).toContain('hard-to-reverse'); + }); + + it('human output shows ReadOnly for sensor devices', async () => { + const meterBody = { + deviceList: [{ + deviceId: 'METER-1', + deviceName: 'Bedroom Meter', + deviceType: 'Meter', + hubDeviceId: 'HUB-1', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValue({ data: { body: meterBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'METER-1']); + const out = res.stdout.join('\n'); + expect(out).toContain('ReadOnly: yes'); + expect(out).toContain('status-only device'); + }); + + it('--live fetches /status and merges it under capabilities.liveStatus', async () => { + apiMock.__instance.get + .mockResolvedValueOnce({ data: { body: sampleBody } }) + .mockResolvedValueOnce({ data: { body: { power: 'on', battery: 87 } } }); + const res = await runCli(registerDevicesCommand, [ + 'devices', + 'describe', + 'BLE-001', + '--live', + '--json', + ]); + expect(apiMock.__instance.get).toHaveBeenCalledTimes(2); + expect(apiMock.__instance.get).toHaveBeenNthCalledWith(1, '/v1.1/devices'); + expect(apiMock.__instance.get).toHaveBeenNthCalledWith(2, '/v1.1/devices/BLE-001/status'); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.source).toBe('catalog+live'); + expect(parsed.capabilities.liveStatus).toEqual({ power: 'on', battery: 87 }); + }); + + it('--live on an IR remote does NOT make a second API call (IR has no status)', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, [ + 'devices', + 'describe', + 'IR-001', + '--live', + '--json', + ]); + expect(apiMock.__instance.get).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.source).toBe('catalog'); + expect(parsed.capabilities.liveStatus).toBeUndefined(); + }); + + it('--live survives a /status failure (records the error)', async () => { + apiMock.__instance.get + .mockResolvedValueOnce({ data: { body: sampleBody } }) + .mockRejectedValueOnce(new Error('device offline')); + const res = await runCli(registerDevicesCommand, [ + 'devices', + 'describe', + 'BLE-001', + '--live', + '--json', + ]); + expect(res.exitCode).toBeNull(); // not a fatal exit + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.source).toBe('catalog+live'); + expect(parsed.capabilities.liveStatus).toHaveProperty('error', 'device offline'); + }); + it('propagates API errors via handleError (exit 1)', async () => { apiMock.__instance.get.mockRejectedValue(new Error('boom')); const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'BLE-001']); diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts new file mode 100644 index 0000000..c284c86 --- /dev/null +++ b/tests/devices/catalog.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { + DEVICE_CATALOG, + findCatalogEntry, + suggestedActions, +} from '../../src/devices/catalog.js'; + +describe('devices/catalog', () => { + describe('schema integrity', () => { + it('every entry has a type, category, and commands array', () => { + for (const entry of DEVICE_CATALOG) { + expect(entry.type).toBeTypeOf('string'); + expect(['physical', 'ir']).toContain(entry.category); + expect(Array.isArray(entry.commands)).toBe(true); + } + }); + + it('every entry has a role assigned', () => { + for (const entry of DEVICE_CATALOG) { + expect( + entry.role, + `${entry.type} is missing a role — new entries must be categorized` + ).toBeTypeOf('string'); + } + }); + + it('status-only entries (no commands) are marked readOnly', () => { + for (const entry of DEVICE_CATALOG) { + if (entry.commands.length === 0 && entry.type !== 'Others') { + expect(entry.readOnly, `${entry.type} has no commands but is not readOnly`).toBe(true); + } + } + }); + + it('has no duplicate type names', () => { + const types = DEVICE_CATALOG.map((e) => e.type); + const unique = new Set(types); + expect(types.length).toBe(unique.size); + }); + }); + + describe('command annotations', () => { + const commandOf = (type: string, cmd: string) => { + const entry = DEVICE_CATALOG.find((e) => e.type === type); + return entry?.commands.find((c) => c.command === cmd); + }; + + it('turnOn / turnOff are idempotent across every device type', () => { + for (const entry of DEVICE_CATALOG) { + for (const c of entry.commands) { + if (c.command === 'turnOn' || c.command === 'turnOff') { + expect( + c.idempotent, + `${entry.type}.${c.command} should be idempotent` + ).toBe(true); + } + } + } + }); + + it('toggle / press / volumeAdd are never idempotent', () => { + const volatileCommands = new Set(['toggle', 'press', 'volumeAdd', 'volumeSub', 'channelAdd', 'channelSub', 'brightnessUp', 'brightnessDown']); + for (const entry of DEVICE_CATALOG) { + for (const c of entry.commands) { + if (volatileCommands.has(c.command)) { + expect( + c.idempotent, + `${entry.type}.${c.command} should not be idempotent` + ).toBe(false); + } + } + } + }); + + 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('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('Keypad createKey/deleteKey are destructive', () => { + expect(commandOf('Keypad', 'createKey')?.destructive).toBe(true); + expect(commandOf('Keypad', 'deleteKey')?.destructive).toBe(true); + }); + + it('Smart Lock `lock` is NOT destructive', () => { + expect(commandOf('Smart Lock', 'lock')?.destructive).toBeFalsy(); + }); + + it('setBrightness / setColor / setColorTemperature carry exampleParams', () => { + for (const entry of DEVICE_CATALOG) { + for (const c of entry.commands) { + if (['setBrightness', 'setColor', 'setColorTemperature'].includes(c.command)) { + expect( + c.exampleParams?.length, + `${entry.type}.${c.command} should have exampleParams` + ).toBeGreaterThan(0); + } + } + } + }); + }); + + describe('role assignments', () => { + const entriesByRole = (role: string) => + DEVICE_CATALOG.filter((e) => e.role === role).map((e) => e.type); + + it('assigns lighting role to the known lighting types', () => { + const lighting = entriesByRole('lighting'); + expect(lighting).toContain('Color Bulb'); + expect(lighting).toContain('Strip Light'); + expect(lighting).toContain('Ceiling Light'); + expect(lighting).toContain('Light'); + }); + + it('assigns security role to locks / doorbell / garage / keypad', () => { + const security = entriesByRole('security'); + expect(security).toContain('Smart Lock'); + expect(security).toContain('Smart Lock Lite'); + expect(security).toContain('Garage Door Opener'); + expect(security).toContain('Keypad'); + expect(security).toContain('Video Doorbell'); + }); + + it('assigns sensor role + readOnly to Meter / Motion Sensor / Contact Sensor', () => { + for (const t of ['Meter', 'Motion Sensor', 'Contact Sensor', 'Water Leak Detector']) { + const entry = DEVICE_CATALOG.find((e) => e.type === t); + expect(entry?.role).toBe('sensor'); + expect(entry?.readOnly).toBe(true); + } + }); + }); + + describe('suggestedActions', () => { + it('returns only idempotent, non-destructive commands', () => { + const lock = DEVICE_CATALOG.find((e) => e.type === 'Smart Lock')!; + const actions = suggestedActions(lock); + // unlock is destructive → must be excluded + expect(actions.find((a) => a.command === 'unlock')).toBeUndefined(); + // lock is idempotent and not destructive → must appear + expect(actions.find((a) => a.command === 'lock')).toBeDefined(); + }); + + it('caps suggestions at 3', () => { + const bulb = DEVICE_CATALOG.find((e) => e.type === 'Color Bulb')!; + const actions = suggestedActions(bulb); + expect(actions.length).toBeLessThanOrEqual(3); + }); + + it('excludes customize commands', () => { + const others = DEVICE_CATALOG.find((e) => e.type === 'Others')!; + expect(suggestedActions(others)).toEqual([]); + }); + + it('returns empty array for readOnly / no-command entries', () => { + const meter = DEVICE_CATALOG.find((e) => e.type === 'Meter')!; + expect(suggestedActions(meter)).toEqual([]); + }); + + it('surfaces exampleParams when a command has them', () => { + const bulb = DEVICE_CATALOG.find((e) => e.type === 'Color Bulb')!; + const actions = suggestedActions(bulb); + // turnOn comes first — no parameters + // The brightness/color commands carry exampleParams, but they're idempotent + // too, so at least one of the picks should have a parameter if the cap + // allowed for it. With cap=3, picks are [turnOn, turnOff, toggle?]... + // turnOn is idempotent, turnOff is idempotent, toggle is NOT idempotent. + // So picks are [turnOn, turnOff, setBrightness]. setBrightness has params. + const withParam = actions.find((a) => a.parameter); + expect(withParam).toBeDefined(); + }); + }); + + describe('findCatalogEntry (existing)', () => { + it('resolves Strip Light 3 via alias to Strip Light', () => { + const match = findCatalogEntry('Strip Light 3'); + expect(Array.isArray(match)).toBe(false); + expect((match as { type: string }).type).toBe('Strip Light'); + }); + }); +}); From 0b9ce16bfa070af06100d40e500c4e7e728fb49b Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 15:52:46 +0800 Subject: [PATCH 02/26] chore: release v1.3.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e39409..de82d7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.1.0", + "version": "1.3.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From ffc10728c97bbdf40b5777057e8f0828ce08b11f Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:01:12 +0800 Subject: [PATCH 03/26] feat(mcp): add MCP server mode; extract shared device/scene logic into src/lib - Add `switchbot mcp serve` (stdio) using @modelcontextprotocol/sdk; exposes seven tools to AI agents over JSON-RPC: list_devices, get_device_status, send_command, list_scenes, run_scene, search_catalog, describe_device - send_command has a built-in destructive-command guard: commands flagged `destructive: true` in the catalog (Smart Lock unlock, Garage Door Opener turnOn/turnOff, Keypad createKey/deleteKey) refuse to execute unless the caller passes `confirm: true`. Returns a structured error_code so the agent can re-issue with the confirmation. - send_command also runs the existing catalog-backed validation (unknown command, unexpected-parameter) before hitting the API, returning a structured error_code instead of a raw API failure. - Extract network + parsing core from commander actions into src/lib/ (lib/devices.ts, lib/scenes.ts) so CLI and MCP share the same code path. Commander actions in src/commands/ are now thin wrappers that only do argument parsing, output formatting, and process.exit. Tests: 342 (+13 MCP). tests/commands/mcp.test.ts covers tool listing, the destructive-requires-confirm guard, confirm-bypass, unknown-command rejection, cold-cache fallback (one list_devices then the command), and the describe_device capabilities/source/live-merge shape. The CLI surface is unchanged; all 329 prior tests still pass against the new lib-backed command implementations. --- package-lock.json | 983 ++++++++++++++++++++++++++++++++++++- src/commands/devices.ts | 243 +++------ src/commands/mcp.ts | 357 ++++++++++++++ src/commands/scenes.ts | 16 +- src/index.ts | 2 + src/lib/devices.ts | 290 +++++++++++ src/lib/scenes.ts | 18 + tests/commands/mcp.test.ts | 311 ++++++++++++ 8 files changed, 2018 insertions(+), 202 deletions(-) create mode 100644 src/commands/mcp.ts create mode 100644 src/lib/devices.ts create mode 100644 src/lib/scenes.ts create mode 100644 tests/commands/mcp.test.ts diff --git a/package-lock.json b/package-lock.json index ba0febc..458a82e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { - "name": "switchbot-openapi-cli", - "version": "1.1.0", + "name": "@switchbot/openapi-cli", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "switchbot-openapi-cli", - "version": "1.1.0", + "name": "@switchbot/openapi-cli", + "version": "1.3.0", + "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "axios": "^1.7.9", "chalk": "^5.4.1", "cli-table3": "^0.6.5", @@ -24,6 +26,9 @@ "tsx": "^4.19.2", "typescript": "^5.7.3", "vitest": "^2.1.9" + }, + "engines": { + "node": ">=18" } }, "node_modules/@ampproject/remapping": { @@ -549,6 +554,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -670,6 +687,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1201,6 +1258,77 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1260,6 +1388,30 @@ "node": "18 || 20 || >=22" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -1273,6 +1425,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1296,6 +1457,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1391,11 +1568,67 @@ "node": ">=18" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1410,7 +1643,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1443,6 +1675,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1464,12 +1705,27 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1564,6 +1820,12 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1574,6 +1836,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.7.tgz", + "integrity": "sha512-zwxwiQqexizSXFZV13zMiEtW1E3lv7RlUv+1f5FBiR4x7wFhEjm3aFTyYkZQWzyN08WnPdox015GoRH5D/E5YA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -1584,6 +1876,135 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/follow-redirects": { "version": "1.15.11", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", @@ -1637,6 +2058,24 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1827,6 +2266,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.14", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", + "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -1834,6 +2282,66 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1843,11 +2351,16 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -1920,6 +2433,27 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1981,6 +2515,27 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -2032,7 +2587,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2054,6 +2608,57 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2061,11 +2666,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2088,6 +2701,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -2112,6 +2735,15 @@ "dev": true, "license": "ISC" }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -2141,6 +2773,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -2150,6 +2795,54 @@ "node": ">=10" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -2205,6 +2898,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2218,11 +2933,86 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2235,12 +3025,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2278,6 +3139,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -2413,6 +3283,15 @@ "node": ">=14.0.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -2433,6 +3312,45 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2454,6 +3372,15 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -2467,6 +3394,15 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -3050,7 +3986,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3185,6 +4120,30 @@ "funding": { "url": "https://github.com/chalk/strip-ansi?sponsor=1" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 1c16c5d..f9fa9f1 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,35 +1,17 @@ import { Command } from 'commander'; -import { createClient } from '../api/client.js'; import { printTable, printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js'; -import { DEVICE_CATALOG, findCatalogEntry, DeviceCatalogEntry, suggestedActions } from '../devices/catalog.js'; -import { getCachedDevice, updateCacheFromDeviceList } from '../devices/cache.js'; - -interface Device { - deviceId: string; - deviceName: string; - deviceType?: string; - enableCloudService: boolean; - hubDeviceId: string; - // Extra fields returned when the request carries the src=OpenClaw header: - roomID?: string; - roomName?: string | null; - familyName?: string; - controlType?: string; -} - -interface InfraredDevice { - deviceId: string; - deviceName: string; - remoteType: string; - hubDeviceId: string; - // Extra field returned when the request carries the src=OpenClaw header: - controlType?: string; -} - -interface DeviceListBody { - deviceList: Device[]; - infraredRemoteList: InfraredDevice[]; -} +import { DEVICE_CATALOG, findCatalogEntry, DeviceCatalogEntry } from '../devices/catalog.js'; +import { getCachedDevice } from '../devices/cache.js'; +import { + fetchDeviceList, + fetchDeviceStatus, + executeCommand, + describeDevice, + validateCommand, + buildHubLocationMap, + DeviceNotFoundError, + type Device, +} from '../lib/devices.js'; export function registerDevicesCommand(program: Command): void { const devices = program @@ -85,14 +67,11 @@ Examples: `) .action(async () => { try { - const client = createClient(); - const res = await client.get<{ body: DeviceListBody }>('/v1.1/devices'); - const { deviceList, infraredRemoteList } = res.data.body; - - updateCacheFromDeviceList(res.data.body); + const body = await fetchDeviceList(); + const { deviceList, infraredRemoteList } = body; if (isJsonMode()) { - printJson(res.data.body); + printJson(body); return; } @@ -167,17 +146,14 @@ Examples: `) .action(async (deviceId: string) => { try { - const client = createClient(); - const res = await client.get<{ body: Record }>( - `/v1.1/devices/${deviceId}/status` - ); + const body = await fetchDeviceStatus(deviceId); if (isJsonMode()) { - printJson(res.data.body); + printJson(body); return; } - printKeyValue(res.data.body); + printKeyValue(body); } catch (error) { handleError(error); } @@ -231,11 +207,26 @@ Examples: $ switchbot devices command ABC123 "MyButton" --type customize `) .action(async (deviceId: string, cmd: string, parameter: string | undefined, options: { type: string }) => { - validateCommandAgainstCache(deviceId, cmd, parameter, options.type); + const validation = validateCommand(deviceId, cmd, parameter, options.type); + if (!validation.ok) { + const err = validation.error; + 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.)` + ); + } + } + process.exit(2); + } try { - const client = createClient(); - // parameter may be a JSON object string (e.g. S10 startClean) or a plain string let parsedParam: unknown = parameter ?? 'default'; if (parameter) { @@ -246,25 +237,21 @@ Examples: } } - const body = { - command: cmd, - parameter: parsedParam, - commandType: options.type, - }; - - const res = await client.post<{ body: unknown }>( - `/v1.1/devices/${deviceId}/commands`, - body + const body = await executeCommand( + deviceId, + cmd, + parsedParam, + options.type as 'command' | 'customize' ); if (isJsonMode()) { - printJson(res.data.body); + printJson(body); return; } console.log(`✓ Command sent: ${cmd}`); - if (res.data.body && typeof res.data.body === 'object' && Object.keys(res.data.body).length > 0) { - printKeyValue(res.data.body as Record); + if (body && typeof body === 'object' && Object.keys(body).length > 0) { + printKeyValue(body as Record); } } catch (error) { handleError(error); @@ -374,67 +361,23 @@ Examples: `) .action(async (deviceId: string, options: { live?: boolean }) => { try { - const client = createClient(); - const res = await client.get<{ body: DeviceListBody }>('/v1.1/devices'); - const { deviceList, infraredRemoteList } = res.data.body; - - updateCacheFromDeviceList(res.data.body); - - const physical = deviceList.find((d) => d.deviceId === deviceId); - const ir = infraredRemoteList.find((d) => d.deviceId === deviceId); - - if (!physical && !ir) { - console.error(`No device with id "${deviceId}" found on this account.`); - console.error(`Try 'switchbot devices list' to see the full list.`); - process.exit(1); - } - - const typeName = physical ? (physical.deviceType ?? '') : ir!.remoteType; - const match = typeName ? findCatalogEntry(typeName) : null; - const catalogEntry = !match || Array.isArray(match) ? null : match; - - // Optionally fetch live status for physical devices. IR remotes have no - // status channel, so --live silently does nothing for them. - let liveStatus: Record | undefined; - if (options.live && physical) { - try { - const statusRes = await client.get<{ body: Record }>( - `/v1.1/devices/${deviceId}/status` - ); - liveStatus = statusRes.data.body; - } catch (err) { - // Don't fail the whole describe when status fails; note it instead. - liveStatus = { error: err instanceof Error ? err.message : String(err) }; - } - } - - const source: 'catalog' | 'live' | 'catalog+live' = catalogEntry - ? (liveStatus ? 'catalog+live' : 'catalog') - : (liveStatus ? 'live' : 'catalog'); + const result = await describeDevice(deviceId, options); + const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result; if (isJsonMode()) { - const capabilities = catalogEntry - ? { - role: catalogEntry.role ?? null, - readOnly: catalogEntry.readOnly ?? false, - commands: catalogEntry.commands, - statusFields: catalogEntry.statusFields ?? [], - ...(liveStatus !== undefined ? { liveStatus } : {}), - } - : (liveStatus !== undefined ? { liveStatus } : null); - printJson({ - device: physical ?? ir, - controlType: (physical?.controlType ?? ir?.controlType) ?? null, - catalog: catalogEntry, + device, + controlType, + catalog, capabilities, source, - suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [], + suggestedActions: picks, }); return; } - if (physical) { + if (isPhysical) { + const physical = device as Device; printKeyValue({ deviceId: physical.deviceId, deviceName: physical.deviceName, @@ -446,8 +389,9 @@ Examples: hub: !physical.hubDeviceId || physical.hubDeviceId === '000000000000' ? '—' : physical.hubDeviceId, cloudService: physical.enableCloudService, }); - } else if (ir) { - const inherited = buildHubLocationMap(deviceList).get(ir.hubDeviceId); + } else { + const ir = device as { deviceId: string; deviceName: string; remoteType: string; controlType?: string; hubDeviceId: string }; + const inherited = result.inheritedLocation; printKeyValue({ deviceId: ir.deviceId, deviceName: ir.deviceName, @@ -460,12 +404,11 @@ Examples: }); } + const liveStatus = + capabilities && 'liveStatus' in capabilities ? capabilities.liveStatus : undefined; + console.log(''); - if (!catalogEntry) { - // When the deviceType isn't in the catalog, distinguish physical vs - // IR: physical devices should use 'devices status'; IR remotes - // expose only user-defined custom buttons. - const isPhysical = Boolean(physical); + if (!catalog) { console.log(`(Type "${typeName}" is not in the built-in catalog — no command reference available.)`); if (isPhysical) { console.log(`Try 'switchbot devices status ${deviceId}' to see what this device reports.`); @@ -478,79 +421,23 @@ Examples: } return; } - renderCatalogEntry(catalogEntry); + renderCatalogEntry(catalog); if (liveStatus) { console.log('\nLive status:'); printKeyValue(liveStatus); } } catch (error) { + if (error instanceof DeviceNotFoundError) { + console.error(error.message); + console.error(`Try 'switchbot devices list' to see the full list.`); + process.exit(1); + } handleError(error); } }); } -function buildHubLocationMap( - deviceList: Device[] -): Map { - const map = new Map(); - for (const d of deviceList) { - if (!d.deviceId) continue; - map.set(d.deviceId, { - family: d.familyName ?? undefined, - room: d.roomName ?? undefined, - roomID: d.roomID ?? undefined, - }); - } - return map; -} - -function validateCommandAgainstCache( - deviceId: string, - cmd: string, - parameter: string | undefined, - commandType: string -): void { - // Custom IR buttons have arbitrary names — skip validation. - if (commandType === 'customize') return; - - const cached = getCachedDevice(deviceId); - if (!cached) return; - - const match = findCatalogEntry(cached.type); - if (!match || Array.isArray(match)) return; - const entry = match; - - const builtinCommands = entry.commands.filter((c) => c.commandType !== 'customize'); - if (builtinCommands.length === 0) return; - - const spec = builtinCommands.find((c) => c.command === cmd); - if (!spec) { - const unique = [...new Set(builtinCommands.map((c) => c.command))]; - console.error( - `Error: "${cmd}" is not a supported command for ${cached.name} (${cached.type}).` - ); - console.error(`Supported commands: ${unique.join(', ')}`); - 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.)` - ); - process.exit(2); - } - - const noParamExpected = spec.parameter === '—'; - const userProvidedParam = parameter !== undefined && parameter !== 'default'; - if (noParamExpected && userProvidedParam) { - console.error( - `Error: "${cmd}" takes no parameter, but one was provided: "${parameter}".` - ); - console.error(`Try: switchbot devices command ${deviceId} ${cmd}`); - process.exit(2); - } -} - function renderCatalogEntry(entry: DeviceCatalogEntry): void { console.log(`Type: ${entry.type}`); console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`); diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts new file mode 100644 index 0000000..1145a71 --- /dev/null +++ b/src/commands/mcp.ts @@ -0,0 +1,357 @@ +import { Command } from 'commander'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; +import { handleError } from '../utils/output.js'; +import { + fetchDeviceList, + fetchDeviceStatus, + executeCommand, + describeDevice, + validateCommand, + isDestructiveCommand, + searchCatalog, + DeviceNotFoundError, +} from '../lib/devices.js'; +import { fetchScenes, executeScene } from '../lib/scenes.js'; +import { findCatalogEntry } from '../devices/catalog.js'; +import { getCachedDevice } from '../devices/cache.js'; + +/** + * Factory — build an McpServer with the six SwitchBot tools registered. + * Exported so tests and alternative transports can reuse it. + */ +export function createSwitchBotMcpServer(): McpServer { + const server = new McpServer( + { + name: 'switchbot', + version: '1.4.0', + }, + { + capabilities: { tools: {} }, + instructions: + 'SwitchBot device control. Before issuing a command with destructive effects (e.g. unlock, garage open, keypad createKey), pass confirm:true. Use search_catalog to discover what a device type supports offline; use describe_device to fetch live capabilities for a specific deviceId.', + } + ); + + // ---- list_devices --------------------------------------------------------- + server.registerTool( + 'list_devices', + { + title: 'List all devices on the account', + description: + 'Fetch the inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local cache.', + inputSchema: {}, + }, + async () => { + const body = await fetchDeviceList(); + return { + content: [ + { + type: 'text', + text: JSON.stringify(body, null, 2), + }, + ], + }; + } + ); + + // ---- get_device_status ---------------------------------------------------- + server.registerTool( + 'get_device_status', + { + title: 'Get live status for a device', + description: + 'Query the real-time status payload for a physical device. IR remotes have no status channel and will error.', + inputSchema: { + deviceId: z.string().describe('Device ID from list_devices'), + }, + }, + async ({ deviceId }) => { + const body = await fetchDeviceStatus(deviceId); + return { + content: [ + { + type: 'text', + text: JSON.stringify(body, null, 2), + }, + ], + }; + } + ); + + // ---- send_command --------------------------------------------------------- + server.registerTool( + 'send_command', + { + title: 'Send a control command to a device', + description: + 'Send a control command (turnOn, setColor, startClean, unlock, ...) to a device. Destructive commands (unlock, garage open, keypad createKey) require confirm:true; otherwise they are rejected.', + inputSchema: { + deviceId: z.string().describe('Device ID from list_devices'), + command: z.string().describe('Command name, case-sensitive (e.g. turnOn, setColor, unlock)'), + parameter: z + .union([z.string(), z.number(), z.boolean(), z.record(z.string(), z.unknown()), z.array(z.unknown())]) + .optional() + .describe('Command parameter. Omit for no-arg commands.'), + commandType: z + .enum(['command', 'customize']) + .optional() + .default('command') + .describe('"command" for built-in commands; "customize" for user-defined IR buttons'), + confirm: z + .boolean() + .optional() + .default(false) + .describe('Required true for destructive commands (unlock, garage open, createKey, ...)'), + }, + }, + async ({ deviceId, command, parameter, commandType, confirm }) => { + const effectiveType = commandType ?? 'command'; + + // Resolve the device's catalog type via cache or a fresh lookup so we + // can evaluate destructive/validation without an extra round-trip if + // the cache is warm. + let typeName = getCachedDevice(deviceId)?.type; + if (!typeName) { + const body = await fetchDeviceList(); + const physical = body.deviceList.find((d) => d.deviceId === deviceId); + const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId); + if (!physical && !ir) { + return { + isError: true, + content: [{ type: 'text', text: `Device not found: ${deviceId}` }], + }; + } + typeName = physical ? physical.deviceType : ir!.remoteType; + } + + if (isDestructiveCommand(typeName, command, effectiveType) && !confirm) { + const entry = typeName ? findCatalogEntry(typeName) : null; + const spec = + entry && !Array.isArray(entry) + ? entry.commands.find((c) => c.command === command) + : undefined; + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: 'destructive_requires_confirm', + message: `Command "${command}" on device type "${typeName}" is destructive and requires confirm:true.`, + command, + deviceType: typeName, + description: spec?.description, + hint: 'Re-issue the call with confirm:true to proceed.', + }, + null, + 2 + ), + }, + ], + }; + } + + // stringifiedParam is what validateCommand expects to decide + // "no-parameter" conflicts — mirror the CLI behavior. + const stringifiedParam = + parameter === undefined ? undefined : typeof parameter === 'string' ? parameter : JSON.stringify(parameter); + const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType); + if (!validation.ok) { + return { + isError: true, + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: 'validation_failed', + message: validation.error.message, + kind: validation.error.kind, + hint: validation.error.hint, + }, + null, + 2 + ), + }, + ], + }; + } + + const result = await executeCommand(deviceId, command, parameter, effectiveType); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + ok: true, + command, + deviceId, + result, + }, + null, + 2 + ), + }, + ], + }; + } + ); + + // ---- run_scene ------------------------------------------------------------ + server.registerTool( + 'run_scene', + { + title: 'Execute a manual scene', + description: 'Execute a manual SwitchBot scene by its sceneId (from list_scenes).', + inputSchema: { + sceneId: z.string().describe('Scene ID from list_scenes'), + }, + }, + async ({ sceneId }) => { + await executeScene(sceneId); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ ok: true, sceneId }, null, 2), + }, + ], + }; + } + ); + + // ---- list_scenes (companion to run_scene) --------------------------------- + server.registerTool( + 'list_scenes', + { + title: 'List all manual scenes', + description: 'Fetch all manual scenes configured in the SwitchBot app.', + inputSchema: {}, + }, + async () => { + const scenes = await fetchScenes(); + return { + content: [ + { + type: 'text', + text: JSON.stringify(scenes, null, 2), + }, + ], + }; + } + ); + + // ---- search_catalog ------------------------------------------------------- + server.registerTool( + 'search_catalog', + { + title: 'Search the offline device catalog', + description: + 'Search the built-in device catalog by type name or alias. Returns matching entries with their commands, roles, destructive flags, and status fields. No API call.', + inputSchema: { + query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'), + limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'), + }, + }, + async ({ query, limit }) => { + const hits = searchCatalog(query, limit); + return { + content: [ + { + type: 'text', + text: JSON.stringify(hits, null, 2), + }, + ], + }; + } + ); + + // ---- describe_device ------------------------------------------------------ + server.registerTool( + 'describe_device', + { + title: 'Describe a specific device', + description: + 'Resolve a deviceId to its metadata + catalog entry + suggested safe actions. Pass live:true to also fetch real-time status values.', + inputSchema: { + deviceId: z.string().describe('Device ID from list_devices'), + live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'), + }, + }, + async ({ deviceId, live }) => { + try { + const result = await describeDevice(deviceId, { live }); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (err) { + if (err instanceof DeviceNotFoundError) { + return { + isError: true, + content: [{ type: 'text', text: err.message }], + }; + } + throw err; + } + } + ); + + return server; +} + +export function registerMcpCommand(program: Command): void { + const mcp = program + .command('mcp') + .description('Run as a Model Context Protocol server so AI agents can call SwitchBot tools') + .addHelpText('after', ` +The MCP server exposes seven tools over stdio: + - list_devices fetch all physical + IR devices + - get_device_status live status for a physical device + - send_command control a device (destructive commands need confirm:true) + - list_scenes list all manual scenes + - run_scene execute a manual scene + - search_catalog offline catalog search by type/alias + - describe_device metadata + commands + (optionally) live status for one device + +Example Claude Desktop config (~/Library/Application Support/Claude/claude_desktop_config.json): + + { + "mcpServers": { + "switchbot": { + "command": "switchbot", + "args": ["mcp", "serve"], + "env": { + "SWITCHBOT_TOKEN": "...", + "SWITCHBOT_SECRET": "..." + } + } + } + } + +Inspect locally: + $ npx @modelcontextprotocol/inspector switchbot mcp serve +`); + + mcp + .command('serve') + .description('Start the MCP server on stdio') + .action(async () => { + try { + const server = createSwitchBotMcpServer(); + const transport = new StdioServerTransport(); + await server.connect(transport); + // stdio transport keeps the process alive; return without exiting. + } catch (error) { + handleError(error); + } + }); +} diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index 9d709ac..67baf5c 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -1,11 +1,6 @@ import { Command } from 'commander'; -import { createClient } from '../api/client.js'; import { printTable, printJson, isJsonMode, handleError } from '../utils/output.js'; - -interface Scene { - sceneId: string; - sceneName: string; -} +import { fetchScenes, executeScene } from '../lib/scenes.js'; export function registerScenesCommand(program: Command): void { const scenes = program @@ -25,15 +20,13 @@ Examples: `) .action(async () => { try { - const client = createClient(); - const res = await client.get<{ body: Scene[] }>('/v1.1/scenes'); + const scenes = await fetchScenes(); if (isJsonMode()) { - printJson(res.data.body); + printJson(scenes); return; } - const scenes = res.data.body; if (scenes.length === 0) { console.log('No scenes found'); return; @@ -59,8 +52,7 @@ Example: `) .action(async (sceneId: string) => { try { - const client = createClient(); - await client.post(`/v1.1/scenes/${sceneId}/execute`); + await executeScene(sceneId); console.log(`✓ Scene executed: ${sceneId}`); } catch (error) { handleError(error); diff --git a/src/index.ts b/src/index.ts index aa73dd0..dbd9d85 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { registerDevicesCommand } from './commands/devices.js'; import { registerScenesCommand } from './commands/scenes.js'; import { registerWebhookCommand } from './commands/webhook.js'; import { registerCompletionCommand } from './commands/completion.js'; +import { registerMcpCommand } from './commands/mcp.js'; const program = new Command(); @@ -25,6 +26,7 @@ registerDevicesCommand(program); registerScenesCommand(program); registerWebhookCommand(program); registerCompletionCommand(program); +registerMcpCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/lib/devices.ts b/src/lib/devices.ts new file mode 100644 index 0000000..e90c514 --- /dev/null +++ b/src/lib/devices.ts @@ -0,0 +1,290 @@ +import type { AxiosInstance } from 'axios'; +import { createClient } from '../api/client.js'; +import { + DEVICE_CATALOG, + findCatalogEntry, + suggestedActions, + type DeviceCatalogEntry, + type CommandSpec, +} from '../devices/catalog.js'; +import { getCachedDevice, updateCacheFromDeviceList } from '../devices/cache.js'; + +export interface Device { + deviceId: string; + deviceName: string; + deviceType?: string; + enableCloudService: boolean; + hubDeviceId: string; + roomID?: string; + roomName?: string | null; + familyName?: string; + controlType?: string; +} + +export interface InfraredDevice { + deviceId: string; + deviceName: string; + remoteType: string; + hubDeviceId: string; + controlType?: string; +} + +export interface DeviceListBody { + deviceList: Device[]; + infraredRemoteList: InfraredDevice[]; +} + +export interface DescribeCapabilities { + role: string | null; + readOnly: boolean; + commands: CommandSpec[]; + statusFields: string[]; + liveStatus?: Record; +} + +export interface DescribeResult { + device: Device | InfraredDevice; + isPhysical: boolean; + typeName: string; + controlType: string | null; + catalog: DeviceCatalogEntry | null; + capabilities: DescribeCapabilities | { liveStatus: Record } | null; + source: 'catalog' | 'live' | 'catalog+live'; + suggestedActions: ReturnType; + /** For IR remotes: the family/room inherited from their bound Hub. Undefined for physical devices. */ + inheritedLocation?: { family?: string; room?: string; roomID?: string }; +} + +export class DeviceNotFoundError extends Error { + constructor(public readonly deviceId: string) { + super(`No device with id "${deviceId}" found on this account.`); + this.name = 'DeviceNotFoundError'; + } +} + +export class CommandValidationError extends Error { + constructor( + message: string, + public readonly kind: 'unknown-command' | 'unexpected-parameter', + public readonly hint?: string + ) { + super(message); + this.name = 'CommandValidationError'; + } +} + +/** Fetch the full device + IR remote inventory and refresh the local cache. */ +export async function fetchDeviceList(client?: AxiosInstance): Promise { + const c = client ?? createClient(); + const res = await c.get<{ body: DeviceListBody }>('/v1.1/devices'); + updateCacheFromDeviceList(res.data.body); + return res.data.body; +} + +/** Fetch live status for a single physical device. IR remotes have no status channel. */ +export async function fetchDeviceStatus( + deviceId: string, + client?: AxiosInstance +): Promise> { + const c = client ?? createClient(); + const res = await c.get<{ body: Record }>( + `/v1.1/devices/${deviceId}/status` + ); + return res.data.body; +} + +/** + * Execute a command on a device. `parameter` is the fully-parsed value already + * (JSON-object when applicable), not a raw CLI string — callers should parse + * upstream if needed. + */ +export async function executeCommand( + deviceId: string, + cmd: string, + parameter: unknown, + commandType: 'command' | 'customize', + client?: AxiosInstance +): Promise { + const c = client ?? createClient(); + const body = { + command: cmd, + parameter: parameter ?? 'default', + commandType, + }; + const res = await c.post<{ body: unknown }>( + `/v1.1/devices/${deviceId}/commands`, + body + ); + return res.data.body; +} + +/** + * Validate a command against the locally-cached device → catalog mapping. + * Returns `{ ok: true }` when validation passes or is skipped (unknown device, + * custom IR button, etc.); returns `{ ok: false, error }` when the caller + * should refuse the call. + */ +export function validateCommand( + deviceId: string, + cmd: string, + parameter: string | undefined, + commandType: string +): { ok: true } | { ok: false; error: CommandValidationError } { + if (commandType === 'customize') return { ok: true }; + + const cached = getCachedDevice(deviceId); + if (!cached) return { ok: true }; + + const match = findCatalogEntry(cached.type); + if (!match || Array.isArray(match)) return { ok: true }; + + const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize'); + if (builtinCommands.length === 0) return { ok: true }; + + const spec = builtinCommands.find((c) => c.command === cmd); + if (!spec) { + const unique = [...new Set(builtinCommands.map((c) => c.command))]; + return { + ok: false, + error: new CommandValidationError( + `"${cmd}" is not a supported command for ${cached.name} (${cached.type}).`, + 'unknown-command', + `Supported commands: ${unique.join(', ')}` + ), + }; + } + + const noParamExpected = spec.parameter === '—'; + const userProvidedParam = parameter !== undefined && parameter !== 'default'; + if (noParamExpected && userProvidedParam) { + return { + ok: false, + error: new CommandValidationError( + `"${cmd}" takes no parameter, but one was provided: "${parameter}".`, + 'unexpected-parameter', + `Try: switchbot devices command ${deviceId} ${cmd}` + ), + }; + } + + return { ok: true }; +} + +/** + * Inspect catalog annotations to decide whether a command is destructive, + * i.e. has hard-to-reverse real-world effects and should require an explicit + * confirmation from an agent / operator before execution. Customize commands + * are considered non-destructive here — they're user-defined IR buttons + * whose behavior the catalog can't know about. + */ +export function isDestructiveCommand( + deviceType: string | undefined, + cmd: string, + commandType: string +): boolean { + if (commandType === 'customize') return false; + if (!deviceType) return false; + const match = findCatalogEntry(deviceType); + if (!match || Array.isArray(match)) return false; + const spec = match.commands.find((c) => c.command === cmd); + return Boolean(spec?.destructive); +} + +/** + * Describe a device by id: metadata + catalog entry (if known) + + * optional live status. Throws `DeviceNotFoundError` when the id is unknown. + */ +export async function describeDevice( + deviceId: string, + options: { live?: boolean } = {}, + client?: AxiosInstance +): Promise { + const body = await fetchDeviceList(client); + const { deviceList, infraredRemoteList } = body; + + const physical = deviceList.find((d) => d.deviceId === deviceId); + const ir = infraredRemoteList.find((d) => d.deviceId === deviceId); + + if (!physical && !ir) throw new DeviceNotFoundError(deviceId); + + const typeName = physical ? (physical.deviceType ?? '') : ir!.remoteType; + const match = typeName ? findCatalogEntry(typeName) : null; + const catalogEntry = !match || Array.isArray(match) ? null : match; + + let liveStatus: Record | undefined; + if (options.live && physical) { + try { + liveStatus = await fetchDeviceStatus(deviceId, client); + } catch (err) { + liveStatus = { error: err instanceof Error ? err.message : String(err) }; + } + } + + const source: 'catalog' | 'live' | 'catalog+live' = catalogEntry + ? liveStatus + ? 'catalog+live' + : 'catalog' + : liveStatus + ? 'live' + : 'catalog'; + + const capabilities: DescribeResult['capabilities'] = catalogEntry + ? { + role: catalogEntry.role ?? null, + readOnly: catalogEntry.readOnly ?? false, + commands: catalogEntry.commands, + statusFields: catalogEntry.statusFields ?? [], + ...(liveStatus !== undefined ? { liveStatus } : {}), + } + : liveStatus !== undefined + ? { liveStatus } + : null; + + return { + device: (physical ?? ir) as Device | InfraredDevice, + isPhysical: Boolean(physical), + typeName, + controlType: physical?.controlType ?? ir?.controlType ?? null, + catalog: catalogEntry, + capabilities, + source, + suggestedActions: catalogEntry ? suggestedActions(catalogEntry) : [], + inheritedLocation: ir ? buildHubLocationMap(deviceList).get(ir.hubDeviceId) : undefined, + }; +} + +/** Build a map from hubDeviceId → room/family/roomID for IR-remote inheritance. */ +export function buildHubLocationMap( + deviceList: Device[] +): Map { + const map = new Map(); + for (const d of deviceList) { + if (!d.deviceId) continue; + map.set(d.deviceId, { + family: d.familyName ?? undefined, + room: d.roomName ?? undefined, + roomID: d.roomID ?? undefined, + }); + } + return map; +} + +/** + * Search the local catalog by type name / alias. Returns up to `limit` + * entries whose type or alias contains the query (case-insensitive). + * Intended for MCP's `search_catalog` tool — not for dispatching commands. + */ +export function searchCatalog(query: string, limit = 20): DeviceCatalogEntry[] { + const q = query.trim().toLowerCase(); + if (!q) return DEVICE_CATALOG.slice(0, limit); + + const hits: DeviceCatalogEntry[] = []; + for (const entry of DEVICE_CATALOG) { + const haystack = [entry.type, ...(entry.aliases ?? [])].map((s) => s.toLowerCase()); + if (haystack.some((h) => h.includes(q))) { + hits.push(entry); + if (hits.length >= limit) break; + } + } + return hits; +} diff --git a/src/lib/scenes.ts b/src/lib/scenes.ts new file mode 100644 index 0000000..38b0014 --- /dev/null +++ b/src/lib/scenes.ts @@ -0,0 +1,18 @@ +import type { AxiosInstance } from 'axios'; +import { createClient } from '../api/client.js'; + +export interface Scene { + sceneId: string; + sceneName: string; +} + +export async function fetchScenes(client?: AxiosInstance): Promise { + const c = client ?? createClient(); + const res = await c.get<{ body: Scene[] }>('/v1.1/scenes'); + return res.data.body; +} + +export async function executeScene(sceneId: string, client?: AxiosInstance): Promise { + const c = client ?? createClient(); + await c.post(`/v1.1/scenes/${sceneId}/execute`); +} diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts new file mode 100644 index 0000000..6684335 --- /dev/null +++ b/tests/commands/mcp.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock the API layer so we don't hit real HTTPS. +// --------------------------------------------------------------------------- +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + }; +}); + +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'; + } + }, +})); + +// Mock the cache so MCP's send_command validation/destructive-check paths are +// deterministic and don't depend on the user's ~/.switchbot/devices.json. +const cacheMock = vi.hoisted(() => { + return { + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), + }; +}); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + loadCache: vi.fn(() => null), + clearCache: vi.fn(), +})); + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; + +/** Connect a fresh server + client pair and return both. */ +async function pair() { + const server = createSwitchBotMcpServer(); + const client = new Client({ name: 'test', version: '0.0.1' }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverT), client.connect(clientT)]); + return { server, client }; +} + +describe('mcp server', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + cacheMock.getCachedDevice.mockClear(); + cacheMock.updateCacheFromDeviceList.mockClear(); + }); + + it('exposes the seven tools with titles and input schemas', async () => { + const { client } = await pair(); + const { tools } = await client.listTools(); + + const names = tools.map((t) => t.name).sort(); + expect(names).toEqual( + [ + 'describe_device', + 'get_device_status', + 'list_devices', + 'list_scenes', + 'run_scene', + 'search_catalog', + 'send_command', + ].sort() + ); + + for (const t of tools) { + expect(t.description, `${t.name} should have a description`).toBeTypeOf('string'); + expect(t.inputSchema, `${t.name} should expose an inputSchema`).toBeDefined(); + expect(t.inputSchema.type).toBe('object'); + } + }); + + it('send_command rejects destructive commands without confirm:true', async () => { + cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front Door', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'LOCK1', command: 'unlock' }, + }); + + expect(res.isError).toBe(true); + const text = (res.content as Array<{ type: string; text: string }>)[0].text; + const parsed = JSON.parse(text); + expect(parsed.error).toBe('destructive_requires_confirm'); + expect(parsed.command).toBe('unlock'); + expect(parsed.deviceType).toBe('Smart Lock'); + // Should not have called the API at all. + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('send_command allows destructive commands when confirm:true', async () => { + cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front Door', category: 'physical' }); + apiMock.__instance.post.mockResolvedValueOnce({ + data: { statusCode: 100, body: { commandId: 'xyz' } }, + }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'LOCK1', command: 'unlock', confirm: true }, + }); + + expect(res.isError).toBeFalsy(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + const [url, body] = apiMock.__instance.post.mock.calls[0]; + expect(url).toBe('/v1.1/devices/LOCK1/commands'); + expect(body).toMatchObject({ command: 'unlock', commandType: 'command' }); + }); + + it('send_command rejects an unknown command name before calling the API', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen Bot', category: 'physical' }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'BOT1', command: 'explode' }, + }); + + expect(res.isError).toBe(true); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.error).toBe('validation_failed'); + expect(parsed.kind).toBe('unknown-command'); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); + + it('send_command sends non-destructive commands through without confirm', async () => { + cacheMock.map.set('BULB1', { type: 'Color Bulb', name: 'Desk Lamp', category: 'physical' }); + apiMock.__instance.post.mockResolvedValueOnce({ + data: { statusCode: 100, body: {} }, + }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'BULB1', command: 'turnOn' }, + }); + + expect(res.isError).toBeFalsy(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + }); + + it('list_devices returns the raw API body and refreshes the cache', async () => { + const body = { deviceList: [], infraredRemoteList: [] }; + apiMock.__instance.get.mockResolvedValueOnce({ data: { statusCode: 100, body } }); + const { client } = await pair(); + + const res = await client.callTool({ name: 'list_devices', arguments: {} }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed).toEqual(body); + expect(cacheMock.updateCacheFromDeviceList).toHaveBeenCalled(); + }); + + it('describe_device returns capabilities with destructive flags surfaced', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ + data: { + statusCode: 100, + body: { + deviceList: [ + { + deviceId: 'LOCK1', + deviceName: 'Front', + deviceType: 'Smart Lock', + enableCloudService: true, + hubDeviceId: 'HUB1', + }, + ], + infraredRemoteList: [], + }, + }, + }); + const { client } = await pair(); + + const res = await client.callTool({ name: 'describe_device', arguments: { deviceId: 'LOCK1' } }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.typeName).toBe('Smart Lock'); + expect(parsed.capabilities.role).toBe('security'); + const unlock = parsed.capabilities.commands.find((c: { command: string }) => c.command === 'unlock'); + expect(unlock.destructive).toBe(true); + }); + + it('describe_device returns isError for a missing deviceId', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { deviceList: [], infraredRemoteList: [] } }, + }); + const { client } = await pair(); + + const res = await client.callTool({ name: 'describe_device', arguments: { deviceId: 'NOPE' } }); + expect(res.isError).toBe(true); + const text = (res.content as Array<{ text: string }>)[0].text; + expect(text).toMatch(/No device with id/); + }); + + it('describe_device with live:true merges /status payload', async () => { + apiMock.__instance.get + .mockResolvedValueOnce({ + data: { + statusCode: 100, + body: { + deviceList: [ + { + deviceId: 'BULB1', + deviceName: 'Desk', + deviceType: 'Color Bulb', + enableCloudService: true, + hubDeviceId: 'HUB1', + }, + ], + infraredRemoteList: [], + }, + }, + }) + .mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on', brightness: 80 } }, + }); + + const { client } = await pair(); + const res = await client.callTool({ name: 'describe_device', arguments: { deviceId: 'BULB1', live: true } }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed.source).toBe('catalog+live'); + expect(parsed.capabilities.liveStatus).toEqual({ power: 'on', brightness: 80 }); + }); + + it('search_catalog returns entries matching by alias', async () => { + const { client } = await pair(); + const res = await client.callTool({ + name: 'search_catalog', + arguments: { query: 'Strip Light 3' }, + }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.some((e: { type: string }) => e.type === 'Strip Light')).toBe(true); + }); + + it('run_scene POSTs the scene execute endpoint', async () => { + apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); + const { client } = await pair(); + + const res = await client.callTool({ name: 'run_scene', arguments: { sceneId: 'S123' } }); + expect(res.isError).toBeFalsy(); + expect(apiMock.__instance.post).toHaveBeenCalledWith('/v1.1/scenes/S123/execute'); + }); + + it('get_device_status forwards the status body', async () => { + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { battery: 82, power: 'on' } }, + }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'get_device_status', + arguments: { deviceId: 'BOT1' }, + }); + const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); + expect(parsed).toEqual({ battery: 82, power: 'on' }); + }); + + it('send_command falls back to a fresh device-list lookup when cache is cold', async () => { + // No cache entry for COLDBOT yet. + apiMock.__instance.get.mockResolvedValueOnce({ + data: { + statusCode: 100, + body: { + deviceList: [ + { + deviceId: 'COLDBOT', + deviceName: 'Bot', + deviceType: 'Bot', + enableCloudService: true, + hubDeviceId: 'HUB1', + }, + ], + infraredRemoteList: [], + }, + }, + }); + apiMock.__instance.post.mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); + const { client } = await pair(); + + const res = await client.callTool({ + name: 'send_command', + arguments: { deviceId: 'COLDBOT', command: 'turnOn' }, + }); + expect(res.isError).toBeFalsy(); + expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/devices'); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + '/v1.1/devices/COLDBOT/commands', + expect.objectContaining({ command: 'turnOn' }) + ); + }); +}); From a7ea893bc384bd35d195f6d44e6f3cbea4072fb5 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:01:12 +0800 Subject: [PATCH 04/26] chore: release v1.4.0 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index de82d7c..01e67c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.3.0", + "version": "1.4.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", @@ -45,6 +45,7 @@ "prepublishOnly": "npm run build && npm test" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "axios": "^1.7.9", "chalk": "^5.4.1", "cli-table3": "^0.6.5", From b8551e1a31713dccf214831b2f49f5e7b198c500 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:08:24 +0800 Subject: [PATCH 05/26] feat(batch): add devices batch with --filter/--ids/stdin, destructive guard, concurrency pool - New `devices batch [param]` subcommand for bulk device control. - Target resolution: --ids (csv), stdin ("-" or --stdin), --filter (DSL). - Filter DSL: type|family|room|category with = (exact) or ~= (substring), comma-separated AND. IR remotes inherit family/room from their hub. - Pre-flight destructive check: Smart Lock unlock / Garage open / Keypad create|delete-key blocked without --yes; exits 2 with per-device reasons. - Concurrency pool (default 5) + 20-60ms jitter between starts (future home for 429 backoff in Step 7). - --dry-run surfaces as "skipped" per device; summary carries dryRun:true. - Structured --json output: { succeeded, failed[{deviceId,error}], summary }. - 10 batch tests + 16 filter tests; 368/368 green. --- src/commands/batch.ts | 318 +++++++++++++++++++++++++++++++++++ src/commands/devices.ts | 4 + src/utils/filter.ts | 135 +++++++++++++++ tests/commands/batch.test.ts | 279 ++++++++++++++++++++++++++++++ tests/utils/filter.test.ts | 113 +++++++++++++ 5 files changed, 849 insertions(+) create mode 100644 src/commands/batch.ts create mode 100644 src/utils/filter.ts create mode 100644 tests/commands/batch.test.ts create mode 100644 tests/utils/filter.test.ts diff --git a/src/commands/batch.ts b/src/commands/batch.ts new file mode 100644 index 0000000..ab16e3b --- /dev/null +++ b/src/commands/batch.ts @@ -0,0 +1,318 @@ +import { Command } from 'commander'; +import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { + fetchDeviceList, + executeCommand, + isDestructiveCommand, + buildHubLocationMap, +} from '../lib/devices.js'; +import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js'; +import { isDryRun } from '../utils/flags.js'; +import { DryRunSignal } from '../api/client.js'; + +interface BatchResult { + succeeded: Array<{ deviceId: string; result: unknown }>; + failed: Array<{ deviceId: string; error: string }>; + summary: { + total: number; + ok: number; + failed: number; + skipped: number; + durationMs: number; + dryRun?: boolean; + }; +} + +const DEFAULT_CONCURRENCY = 5; + +/** Run `task(x)` for every element with at most `concurrency` running at once. */ +async function runPool( + items: T[], + concurrency: number, + task: (item: T) => Promise +): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + const workers: Promise[] = []; + const width = Math.max(1, Math.min(concurrency, items.length)); + + for (let w = 0; w < width; w++) { + workers.push( + (async () => { + while (cursor < items.length) { + const idx = cursor++; + results[idx] = await task(items[idx]); + // Tiny jitter between starts so we don't hammer the endpoint in a + // perfectly aligned burst. Keeps the default concurrency=5 polite. + await new Promise((r) => setTimeout(r, 20 + Math.random() * 40)); + } + })() + ); + } + + await Promise.all(workers); + return results; +} + +async function resolveTargetIds(options: { + filter?: string; + ids?: string; + readStdin: boolean; +}): Promise<{ ids: string[]; typeMap: Map }> { + const explicit: string[] = []; + + if (options.ids) { + for (const id of options.ids.split(',').map((s) => s.trim()).filter(Boolean)) { + explicit.push(id); + } + } + + if (options.readStdin) { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString('utf-8'); + for (const line of raw.split(/\r?\n/)) { + const id = line.trim(); + if (id) explicit.push(id); + } + } + + const hasFilter = Boolean(options.filter); + if (explicit.length === 0 && !hasFilter) { + throw new Error( + 'No target devices supplied — provide --ids, --filter, or pass "-" to read deviceIds from stdin.' + ); + } + + // Always fetch the device list so we can (a) apply --filter when present + // and (b) build a deviceId → deviceType map for destructive/validation + // checks regardless of how the ids were provided. + const body = await fetchDeviceList(); + const hubLoc = buildHubLocationMap(body.deviceList); + + const typeMap = new Map(); + for (const d of body.deviceList) if (d.deviceType) typeMap.set(d.deviceId, d.deviceType); + for (const ir of body.infraredRemoteList) typeMap.set(ir.deviceId, ir.remoteType); + + let ids: string[]; + if (hasFilter) { + const clauses = parseFilter(options.filter); + const matched = applyFilter(clauses, body.deviceList, body.infraredRemoteList, hubLoc); + const filteredIds = new Set(matched.map((m) => m.deviceId)); + ids = + explicit.length > 0 ? explicit.filter((id) => filteredIds.has(id)) : [...filteredIds]; + } else { + ids = explicit; + } + + return { ids, typeMap }; +} + +export function registerBatchCommand(devices: Command): void { + devices + .command('batch') + .description('Send the same command to many devices in one run (filter- or stdin-driven)') + .argument('', 'Command name, e.g. turnOn, turnOff, setBrightness') + .argument('[parameter]', 'Command parameter (same rules as `devices command`; omit for no-arg)') + .option('--filter ', 'Target devices matching a filter, e.g. type=Bot,family=Home') + .option('--ids ', 'Explicit comma-separated list of deviceIds') + .option('--concurrency ', 'Max parallel in-flight requests (default 5)', '5') + .option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)') + .option('--type ', '"command" (default) or "customize" for user-defined IR buttons', 'command') + .option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")') + .addHelpText('after', ` +Targets are resolved in this priority order: + 1. --ids when present (explicit deviceIds) + 2. stdin when --stdin / "-" (one deviceId per line) + 3. --filter (matches the account's device list) + You can combine explicit ids with --filter to intersect them. + +Filter grammar: + key=value exact match + key~=value case-insensitive substring match + clauses are comma-separated AND + +Supported keys: type, family, room, category (category: physical | ir) + +Output: + Human mode: one status line per device, summary at the end. + --json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs}} + +Safety: + Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff, + Keypad createKey/deleteKey) are blocked by default. Pass --yes to override. + --dry-run intercepts every POST and reports the intended calls without + hitting the API. + +Examples: + $ switchbot devices batch turnOff --filter 'type~=Light,family=家里' + $ switchbot devices batch turnOn --ids ID1,ID2,ID3 + $ switchbot devices list --format=id --filter 'type=Bot' | switchbot devices batch toggle - + $ switchbot devices batch unlock --filter 'type=Smart Lock' --yes +`) + .action( + async ( + cmd: string, + parameter: string | undefined, + options: { + filter?: string; + ids?: string; + concurrency: string; + yes?: boolean; + type: string; + stdin?: boolean; + }, + commandObj: Command + ) => { + // Trailing "-" sentinel selects stdin mode. + const extra = commandObj.args ?? []; + const readStdin = Boolean(options.stdin) || extra.includes('-'); + + let resolved: Awaited>; + try { + resolved = await resolveTargetIds({ + filter: options.filter, + ids: options.ids, + readStdin, + }); + } catch (error) { + if (error instanceof FilterSyntaxError) { + console.error(`Error: ${error.message}`); + process.exit(2); + } + if (error instanceof Error && error.message.startsWith('No target devices')) { + console.error(`Error: ${error.message}`); + process.exit(2); + } + handleError(error); + } + + if (resolved.ids.length === 0) { + const out: BatchResult = { + succeeded: [], + failed: [], + summary: { total: 0, ok: 0, failed: 0, skipped: 0, durationMs: 0 }, + }; + if (isJsonMode()) printJson(out); + else console.log('No devices matched — nothing to do.'); + return; + } + + const effectiveType = (options.type === 'customize' ? 'customize' : 'command') as + | 'command' + | 'customize'; + + // Pre-flight: identify destructive targets before spending API calls. + const blockedForDestructive: Array<{ deviceId: string; reason: string }> = []; + for (const id of resolved.ids) { + const t = resolved.typeMap.get(id); + if (isDestructiveCommand(t, cmd, effectiveType) && !options.yes) { + blockedForDestructive.push({ + deviceId: id, + reason: `destructive command "${cmd}" on ${t ?? 'unknown'} requires --yes`, + }); + } + } + + if (blockedForDestructive.length > 0 && !options.yes) { + const out: BatchResult = { + succeeded: [], + failed: blockedForDestructive.map((b) => ({ + deviceId: b.deviceId, + error: b.reason, + })), + summary: { + total: resolved.ids.length, + ok: 0, + failed: blockedForDestructive.length, + skipped: resolved.ids.length - blockedForDestructive.length, + durationMs: 0, + }, + }; + if (isJsonMode()) { + printJson(out); + } 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); + } + + // parameter may be a JSON object string; mirror the single-command action. + let parsedParam: unknown = parameter ?? 'default'; + if (parameter) { + try { + parsedParam = JSON.parse(parameter); + } catch { + // keep as string + } + } + + const concurrency = Math.max(1, Number.parseInt(options.concurrency, 10) || DEFAULT_CONCURRENCY); + const dryRun = isDryRun(); + const startedAt = Date.now(); + + const outcomes = await runPool(resolved.ids, concurrency, async (id) => { + try { + const result = await executeCommand(id, cmd, parsedParam, effectiveType); + if (!isJsonMode()) { + console.log(`✓ ${id}: ${cmd}`); + } + return { ok: true as const, deviceId: id, result }; + } catch (err) { + // --dry-run uses DryRunSignal to short-circuit; surface that as a + // "skipped" outcome, not a failure. + if (err instanceof DryRunSignal) { + return { ok: 'dry-run' as const, deviceId: id }; + } + const message = err instanceof Error ? err.message : String(err); + if (!isJsonMode()) { + console.error(`✗ ${id}: ${message}`); + } + return { ok: false as const, deviceId: id, error: message }; + } + }); + + const succeeded = outcomes.filter((o) => o.ok === true) as Array<{ + ok: true; + deviceId: string; + result: unknown; + }>; + const failed = outcomes.filter((o) => o.ok === false) as Array<{ + ok: false; + deviceId: string; + error: string; + }>; + const dryRunned = outcomes.filter((o) => o.ok === 'dry-run') as Array<{ + ok: 'dry-run'; + deviceId: string; + }>; + + const result: BatchResult = { + succeeded: succeeded.map((s) => ({ deviceId: s.deviceId, result: s.result })), + failed: failed.map((f) => ({ deviceId: f.deviceId, error: f.error })), + summary: { + total: resolved.ids.length, + ok: succeeded.length, + failed: failed.length, + skipped: dryRunned.length, + durationMs: Date.now() - startedAt, + ...(dryRun ? { dryRun: true } : {}), + }, + }; + + if (isJsonMode()) { + printJson(result); + } else { + console.log( + `\nSummary: ${result.summary.ok} ok, ${result.summary.failed} failed, ${result.summary.skipped} skipped (${result.summary.durationMs}ms)` + ); + } + + // Non-zero exit when anything failed so scripts can react. + if (failed.length > 0) process.exit(1); + } + ); +} diff --git a/src/commands/devices.ts b/src/commands/devices.ts index f9fa9f1..7376ed5 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -12,6 +12,7 @@ import { DeviceNotFoundError, type Device, } from '../lib/devices.js'; +import { registerBatchCommand } from './batch.js'; export function registerDevicesCommand(program: Command): void { const devices = program @@ -436,6 +437,9 @@ Examples: handleError(error); } }); + + // switchbot devices batch ... + registerBatchCommand(devices); } function renderCatalogEntry(entry: DeviceCatalogEntry): void { diff --git a/src/utils/filter.ts b/src/utils/filter.ts new file mode 100644 index 0000000..b85755d --- /dev/null +++ b/src/utils/filter.ts @@ -0,0 +1,135 @@ +import type { Device, InfraredDevice } from '../lib/devices.js'; + +/** + * A parsed filter clause. Each clause is an (op, key, value) triple that runs + * against a candidate device. All clauses from a single expression are AND-ed. + */ +export interface FilterClause { + key: 'type' | 'family' | 'room' | 'category'; + op: '=' | '~='; + value: string; +} + +export class FilterSyntaxError extends Error { + constructor(message: string) { + super(message); + this.name = 'FilterSyntaxError'; + } +} + +const VALID_KEYS: FilterClause['key'][] = ['type', 'family', 'room', 'category']; + +/** + * Parse a filter expression like "type=Bot,family=Home" into discrete clauses. + * + * Grammar: + * expr := clause ("," clause)* + * clause := KEY OP VALUE + * KEY := type | family | room | category + * OP := "=" | "~=" + * VALUE := any non-empty string (no comma — split at the clause boundary) + * + * Whitespace around keys / values is trimmed. Empty expressions return []. + */ +export function parseFilter(expr: string | undefined): FilterClause[] { + if (!expr) return []; + const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + const clauses: FilterClause[] = []; + + for (const part of parts) { + const m = /^([a-zA-Z_]+)\s*(~=|=)\s*(.+)$/.exec(part); + if (!m) { + throw new FilterSyntaxError( + `Invalid filter clause "${part}" — expected "=" or "~="` + ); + } + const key = m[1] as FilterClause['key']; + const op = m[2] as FilterClause['op']; + const value = m[3].trim(); + if (!VALID_KEYS.includes(key)) { + throw new FilterSyntaxError( + `Unknown filter key "${key}" — supported: ${VALID_KEYS.join(', ')}` + ); + } + if (!value) { + throw new FilterSyntaxError(`Empty value for filter clause "${part}"`); + } + clauses.push({ key, op, value }); + } + + return clauses; +} + +interface FilterableDevice { + deviceId: string; + type: string; + family?: string; + room?: string; + category: 'physical' | 'ir'; +} + +/** Normalize a physical / IR device entry to the shape the filter matcher expects. */ +function toFilterable( + d: Device | InfraredDevice, + isPhysical: boolean, + hubLocation?: Map +): FilterableDevice { + if (isPhysical) { + const p = d as Device; + return { + deviceId: p.deviceId, + type: p.deviceType ?? '', + family: p.familyName ?? undefined, + room: p.roomName ?? undefined, + category: 'physical', + }; + } + const ir = d as InfraredDevice; + const inherited = hubLocation?.get(ir.hubDeviceId); + return { + deviceId: ir.deviceId, + type: ir.remoteType ?? '', + family: inherited?.family, + room: inherited?.room, + category: 'ir', + }; +} + +function matches(d: FilterableDevice, clause: FilterClause): boolean { + const candidate: string | undefined = + clause.key === 'type' + ? d.type + : clause.key === 'family' + ? d.family + : clause.key === 'room' + ? d.room + : d.category; + if (candidate === undefined) return false; + + if (clause.op === '=') return candidate.toLowerCase() === clause.value.toLowerCase(); + + // '~=' — case-insensitive substring match on the candidate. + return candidate.toLowerCase().includes(clause.value.toLowerCase()); +} + +/** + * Apply the parsed clauses to a mixed list of physical devices + IR remotes. + * Returns the deviceIds of the entries that satisfy every clause. + * + * `hubLocation` (optional) allows family/room filters to match IR remotes by + * the Hub-inherited location. + */ +export function applyFilter( + clauses: FilterClause[], + deviceList: Device[], + infraredRemoteList: InfraredDevice[], + hubLocation?: Map +): FilterableDevice[] { + const candidates: FilterableDevice[] = [ + ...deviceList.map((d) => toFilterable(d, true)), + ...infraredRemoteList.map((d) => toFilterable(d, false, hubLocation)), + ]; + + if (clauses.length === 0) return candidates; + return candidates.filter((c) => clauses.every((clause) => matches(c, clause))); +} diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts new file mode 100644 index 0000000..6ecd3c9 --- /dev/null +++ b/tests/commands/batch.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, + }; +}); + +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: apiMock.DryRunSignal, +})); + +// Cache: keep deterministic across tests. +const cacheMock = vi.hoisted(() => ({ + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), +})); +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + loadCache: vi.fn(() => null), + clearCache: vi.fn(), +})); + +// Flags: dryRun toggleable per test. +const flagsMock = vi.hoisted(() => ({ + dryRun: false, + isDryRun: vi.fn(() => flagsMock.dryRun), + isVerbose: vi.fn(() => false), + getTimeout: vi.fn(() => 30000), + getConfigPath: vi.fn(() => undefined), +})); +vi.mock('../../src/utils/flags.js', () => flagsMock); + +import { registerDevicesCommand } from '../../src/commands/devices.js'; +import { runCli } from '../helpers/cli.js'; + +const DEVICE_LIST_BODY = { + deviceList: [ + { + deviceId: 'BOT1', + deviceName: 'Kitchen', + deviceType: 'Bot', + familyName: 'Home', + roomName: 'Kitchen', + enableCloudService: true, + hubDeviceId: 'HUB1', + }, + { + deviceId: 'BOT2', + deviceName: 'Office', + deviceType: 'Bot', + familyName: 'Home', + roomName: 'Office', + enableCloudService: true, + hubDeviceId: 'HUB1', + }, + { + deviceId: 'LOCK1', + deviceName: 'Front', + deviceType: 'Smart Lock', + familyName: 'Home', + roomName: 'Entry', + enableCloudService: true, + hubDeviceId: 'HUB1', + }, + ], + infraredRemoteList: [], +}; + +describe('devices batch', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + cacheMock.getCachedDevice.mockClear(); + flagsMock.dryRun = false; + }); + + it('refuses to run without --ids / --filter / stdin', async () => { + const result = await runCli(registerDevicesCommand, ['devices', 'batch', 'turnOn']); + expect(result.exitCode).toBe(2); + expect(result.stderr.join('\n')).toMatch(/No target devices/); + }); + + it('rejects an unknown filter key', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + const result = await runCli(registerDevicesCommand, [ + 'devices', + 'batch', + 'turnOn', + '--filter', + 'color=red', + ]); + expect(result.exitCode).toBe(2); + expect(result.stderr.join('\n')).toMatch(/Unknown filter key/); + }); + + it('dispatches turnOn to every device selected by --filter', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--filter', + 'type=Bot', + ]); + + expect(result.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.summary.ok).toBe(2); + expect(parsed.summary.failed).toBe(0); + expect(parsed.succeeded.map((s: { deviceId: string }) => s.deviceId).sort()).toEqual(['BOT1', 'BOT2']); + }); + + it('dispatches by --ids (intersected with --filter when both are set)', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--ids', + 'BOT1,BOT2,LOCK1', + '--filter', + 'type=Bot', + ]); + + expect(result.exitCode).toBeNull(); + // Only BOT1 and BOT2 pass the filter — LOCK1 is excluded. + expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.summary.total).toBe(2); + }); + + it('surfaces partial failures in the failed[] array and exits 1', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + // BOT1 succeeds, BOT2 fails. + apiMock.__instance.post + .mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }) + .mockRejectedValueOnce(new Error('timeout')); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--ids', + 'BOT1,BOT2', + ]); + + expect(result.exitCode).toBe(1); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.summary.ok).toBe(1); + expect(parsed.summary.failed).toBe(1); + expect(parsed.failed[0].deviceId).toBe('BOT2'); + expect(parsed.failed[0].error).toMatch(/timeout/); + }); + + it('refuses destructive commands without --yes', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + + const result = await runCli(registerDevicesCommand, [ + 'devices', + 'batch', + 'unlock', + '--ids', + 'LOCK1', + ]); + + expect(result.exitCode).toBe(2); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + expect(result.stderr.join('\n')).toMatch(/destructive/); + }); + + it('allows destructive commands when --yes is passed', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'unlock', + '--ids', + 'LOCK1', + '--yes', + ]); + + expect(result.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.summary.ok).toBe(1); + }); + + it('--dry-run does not send POSTs and marks all as skipped', async () => { + flagsMock.dryRun = true; + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + // The real DryRunSignal would be raised by the axios interceptor at request + // time; from the batch layer's perspective, our mocked `post` simulates + // the throw. + apiMock.__instance.post.mockImplementation(async () => { + throw new apiMock.DryRunSignal('POST', '/v1.1/devices/BOT1/commands'); + }); + + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--ids', + 'BOT1,BOT2', + ]); + + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.summary.ok).toBe(0); + expect(parsed.summary.failed).toBe(0); + expect(parsed.summary.skipped).toBe(2); + expect(parsed.summary.dryRun).toBe(true); + }); + + it('prints a human summary line when not in JSON mode', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const result = await runCli(registerDevicesCommand, [ + 'devices', + 'batch', + 'turnOn', + '--filter', + 'type=Bot', + ]); + + expect(result.exitCode).toBeNull(); + const combined = result.stdout.join('\n'); + expect(combined).toMatch(/✓ BOT1/); + expect(combined).toMatch(/✓ BOT2/); + expect(combined).toMatch(/Summary: 2 ok, 0 failed/); + }); + + it('reports zero matches without calling POST', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { statusCode: 100, body: DEVICE_LIST_BODY } }); + const result = await runCli(registerDevicesCommand, [ + '--json', + 'devices', + 'batch', + 'turnOn', + '--filter', + 'type=Unicorn', + ]); + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.summary.total).toBe(0); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/utils/filter.test.ts b/tests/utils/filter.test.ts new file mode 100644 index 0000000..335d7fd --- /dev/null +++ b/tests/utils/filter.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { parseFilter, applyFilter, FilterSyntaxError } from '../../src/utils/filter.js'; +import type { Device, InfraredDevice } from '../../src/lib/devices.js'; + +const devices: Device[] = [ + { deviceId: 'BOT1', deviceName: 'Kitchen Bot', deviceType: 'Bot', familyName: 'Home', roomName: 'Kitchen', enableCloudService: true, hubDeviceId: 'HUB1' }, + { deviceId: 'BOT2', deviceName: 'Office Bot', deviceType: 'Bot', familyName: 'Home', roomName: 'Office', enableCloudService: true, hubDeviceId: 'HUB1' }, + { deviceId: 'LAMP', deviceName: 'Desk', deviceType: 'Color Bulb', familyName: 'Home', roomName: 'Office', enableCloudService: true, hubDeviceId: 'HUB1' }, + { deviceId: 'METER', deviceName: 'Outside', deviceType: 'Meter', familyName: 'Cabin', roomName: 'Porch', enableCloudService: true, hubDeviceId: 'HUB2' }, +]; + +const irRemotes: InfraredDevice[] = [ + { deviceId: 'TV1', deviceName: 'Living TV', remoteType: 'TV', hubDeviceId: 'HUB1' }, + { deviceId: 'AC1', deviceName: 'Bedroom AC', remoteType: 'Air Conditioner', hubDeviceId: 'HUB2' }, +]; + +const hubLoc = new Map([ + ['HUB1', { family: 'Home', room: 'Living' }], + ['HUB2', { family: 'Cabin', room: 'Bedroom' }], +]); + +describe('parseFilter', () => { + it('returns [] for undefined / empty string', () => { + expect(parseFilter(undefined)).toEqual([]); + expect(parseFilter('')).toEqual([]); + expect(parseFilter(' ')).toEqual([]); + }); + + it('parses a single exact clause', () => { + expect(parseFilter('type=Bot')).toEqual([{ key: 'type', op: '=', value: 'Bot' }]); + }); + + it('parses a substring (~=) clause', () => { + expect(parseFilter('type~=Light')).toEqual([{ key: 'type', op: '~=', value: 'Light' }]); + }); + + it('parses multi-clause AND expressions', () => { + const clauses = parseFilter('type=Bot,family=Home'); + expect(clauses).toHaveLength(2); + expect(clauses[0].key).toBe('type'); + expect(clauses[1].key).toBe('family'); + }); + + it('trims whitespace around keys and values', () => { + const [c] = parseFilter(' type = Bot Plus '); + expect(c).toEqual({ key: 'type', op: '=', value: 'Bot Plus' }); + }); + + it('rejects unknown keys', () => { + expect(() => parseFilter('color=red')).toThrow(FilterSyntaxError); + }); + + it('rejects malformed clauses (no operator)', () => { + expect(() => parseFilter('foo')).toThrow(FilterSyntaxError); + }); + + it('rejects empty values', () => { + expect(() => parseFilter('type=')).toThrow(FilterSyntaxError); + }); +}); + +describe('applyFilter', () => { + it('returns every candidate when the clause list is empty', () => { + const all = applyFilter([], devices, irRemotes, hubLoc); + expect(all.map((d) => d.deviceId).sort()).toEqual( + ['AC1', 'BOT1', 'BOT2', 'LAMP', 'METER', 'TV1'] + ); + }); + + it('filters by exact type on physical devices', () => { + const matched = applyFilter(parseFilter('type=Bot'), devices, irRemotes, hubLoc); + expect(matched.map((d) => d.deviceId).sort()).toEqual(['BOT1', 'BOT2']); + }); + + it('substring match with ~= is case-insensitive', () => { + const matched = applyFilter(parseFilter('type~=light'), devices, irRemotes, hubLoc); + // "Color Bulb" doesn't contain "light", so only the IR remotes that do — none here. + // Let's check against a real substring. + const meter = applyFilter(parseFilter('type~=met'), devices, irRemotes, hubLoc); + expect(meter.map((d) => d.deviceId)).toEqual(['METER']); + expect(matched).toEqual([]); // Color Bulb / Meter / Bot / TV / AC: none contain 'light' + }); + + it('AND-joins multiple clauses', () => { + const matched = applyFilter( + parseFilter('type=Bot,room=Office'), + devices, + irRemotes, + hubLoc + ); + expect(matched.map((d) => d.deviceId)).toEqual(['BOT2']); + }); + + it('matches IR remotes by family inherited from the hub', () => { + const matched = applyFilter(parseFilter('family=Cabin'), devices, irRemotes, hubLoc); + expect(matched.map((d) => d.deviceId).sort()).toEqual(['AC1', 'METER']); + }); + + it('filters by category=ir', () => { + const matched = applyFilter(parseFilter('category=ir'), devices, irRemotes, hubLoc); + expect(matched.map((d) => d.deviceId).sort()).toEqual(['AC1', 'TV1']); + }); + + it('filters by category=physical', () => { + const matched = applyFilter(parseFilter('category=physical'), devices, irRemotes, hubLoc); + expect(matched.map((d) => d.deviceId).sort()).toEqual(['BOT1', 'BOT2', 'LAMP', 'METER']); + }); + + it('returns empty when a clause has no matches', () => { + const matched = applyFilter(parseFilter('type=Unicorn'), devices, irRemotes, hubLoc); + expect(matched).toEqual([]); + }); +}); From 57e4724c3ef4f2314e1a378f83696bef1911f17a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:08:30 +0800 Subject: [PATCH 06/26] chore: release v1.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 6 of the AI-agent roadmap: batch primitive — filter DSL, stdin pipeline, destructive guard, concurrency pool. Unlocks `switchbot devices list --format=id | switchbot devices batch turnOff -` style agent workflows. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 01e67c7..24ffb90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.4.0", + "version": "1.5.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From e56e260841537360f5e002361261a1d33a4e3c6e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:15:11 +0800 Subject: [PATCH 07/26] feat(resilience): 429 retry with Retry-After + exponential backoff, local quota counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response interceptor now transparently retries HTTP 429 responses up to --retry-on-429 times (default 3). Retry delay honors the server's Retry-After header when present; otherwise uses exponential backoff (1s, 2s, 4s, ..., capped at 30s). --no-retry disables the behavior; --backoff linear switches to a linear schedule. Every request hit is counted in ~/.switchbot/quota.json, bucketed by local date and endpoint pattern (device/scene IDs collapsed to :id). New `switchbot quota status|reset` command surfaces today's usage and the last 7 days. Recording is best-effort file I/O — a failed write never breaks the real API call; --no-quota opts out entirely. ApiError now carries { retryable, hint } metadata so agents can differentiate transient failures (429 after retry exhaustion, 5xx) from permanent ones (401, 152, 160). 33 new tests (retry math, quota bucketing, quota command, retry path). 401/401 green. --- src/api/client.ts | 92 +++++++++++++++-- src/commands/quota.ts | 86 ++++++++++++++++ src/index.ts | 6 ++ src/utils/flags.ts | 29 ++++++ src/utils/quota.ts | 159 +++++++++++++++++++++++++++++ src/utils/retry.ts | 67 ++++++++++++ tests/api/client.test.ts | 191 +++++++++++++++++++++++++++++++++++ tests/commands/quota.test.ts | 78 ++++++++++++++ tests/utils/quota.test.ts | 123 ++++++++++++++++++++++ tests/utils/retry.test.ts | 81 +++++++++++++++ 10 files changed, 904 insertions(+), 8 deletions(-) create mode 100644 src/commands/quota.ts create mode 100644 src/utils/quota.ts create mode 100644 src/utils/retry.ts create mode 100644 tests/commands/quota.test.ts create mode 100644 tests/utils/quota.test.ts create mode 100644 tests/utils/retry.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index 32010af..90fb9b1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,8 +1,21 @@ -import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'; +import axios, { + type AxiosInstance, + type InternalAxiosRequestConfig, + type AxiosResponse, +} from 'axios'; import chalk from 'chalk'; import { buildAuthHeaders } from '../auth.js'; import { loadConfig } from '../config.js'; -import { isVerbose, isDryRun, getTimeout } from '../utils/flags.js'; +import { + isVerbose, + isDryRun, + getTimeout, + getRetryOn429, + getBackoffStrategy, + isQuotaDisabled, +} from '../utils/flags.js'; +import { nextRetryDelayMs, sleep } from '../utils/retry.js'; +import { recordRequest } from '../utils/quota.js'; const API_ERROR_MESSAGES: Record = { 151: 'Device type does not support this command', @@ -21,10 +34,15 @@ export class DryRunSignal extends Error { } } +type RetryableConfig = InternalAxiosRequestConfig & { __retryCount?: number }; + export function createClient(): AxiosInstance { const { token, secret } = loadConfig(); const verbose = isVerbose(); const dryRun = isDryRun(); + const maxRetries = getRetryOn429(); + const backoff = getBackoffStrategy(); + const quotaEnabled = !isQuotaDisabled(); const client = axios.create({ baseURL: 'https://api.switch-bot.com', @@ -59,10 +77,15 @@ export function createClient(): AxiosInstance { // Handle API-level errors (HTTP 200 but statusCode !== 100) client.interceptors.response.use( - (response) => { + (response: AxiosResponse) => { 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 = @@ -79,19 +102,62 @@ export function createClient(): AxiosInstance { if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { throw new ApiError( `Request timed out after ${getTimeout()}ms (override with --timeout )`, - 0 + 0, + { retryable: false } ); } const status = error.response?.status; + const config = error.config as RetryableConfig | undefined; + + // 429 → transparent retry with Retry-After / exponential backoff. + // Skipped when: no config (shouldn't happen for real axios errors), + // retries disabled, or we've already used our budget. + if (status === 429 && config && maxRetries > 0) { + const attempt = config.__retryCount ?? 0; + if (attempt < maxRetries) { + config.__retryCount = attempt + 1; + const delay = nextRetryDelayMs( + attempt, + backoff, + error.response?.headers?.['retry-after'] + ); + if (verbose) { + process.stderr.write( + chalk.grey( + `[verbose] 429 received — retry ${attempt + 1}/${maxRetries} in ${delay}ms\n` + ) + ); + } + return sleep(delay).then(() => client.request(config)); + } + } + + // 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); + } + if (status === 401) { - throw new ApiError('Authentication failed: invalid token or daily 10,000-request quota exceeded', 401); + throw new ApiError( + 'Authentication failed: invalid token or daily 10,000-request quota exceeded', + 401, + { retryable: false, hint: 'Run `switchbot config set-token ` to re-enter credentials, or `switchbot quota status` to check today\'s local count.' } + ); } if (status === 429) { - throw new ApiError('Request rate too high: daily 10,000-request quota exceeded', 429); + throw new ApiError( + 'Request rate too high: daily 10,000-request quota exceeded (retries exhausted)', + 429, + { retryable: true, hint: 'Use `switchbot quota status` to see today\'s usage; raise `--retry-on-429 ` for more retries.' } + ); } throw new ApiError( `HTTP ${status ?? '?'}: ${error.message}`, - status ?? 0 + status ?? 0, + { retryable: status !== undefined && status >= 500 } ); } throw error; @@ -101,12 +167,22 @@ export function createClient(): AxiosInstance { return client; } +export interface ApiErrorMeta { + retryable?: boolean; + hint?: string; +} + export class ApiError extends Error { + public readonly retryable: boolean; + public readonly hint?: string; constructor( message: string, - public readonly code: number + public readonly code: number, + meta: ApiErrorMeta = {} ) { super(message); this.name = 'ApiError'; + this.retryable = meta.retryable ?? false; + this.hint = meta.hint; } } diff --git a/src/commands/quota.ts b/src/commands/quota.ts new file mode 100644 index 0000000..0053820 --- /dev/null +++ b/src/commands/quota.ts @@ -0,0 +1,86 @@ +import { Command } from 'commander'; +import { printJson, isJsonMode } from '../utils/output.js'; +import { + DAILY_QUOTA, + loadQuota, + resetQuota, + todayUsage, +} from '../utils/quota.js'; + +export function registerQuotaCommand(program: Command): void { + const quota = program + .command('quota') + .description('Inspect and manage the local SwitchBot API request counter') + .addHelpText('after', ` +Every request the CLI makes is counted locally in ~/.switchbot/quota.json. +Counts are bucketed by local date, one record per endpoint pattern. This +is a best-effort mirror of the SwitchBot 10,000/day limit — it does not +include requests made outside this CLI (mobile app, other scripts). + +Subcommands: + status Show today's usage and the last 7 days + reset Delete the local counter file + +Examples: + $ switchbot quota status + $ switchbot quota status --json + $ switchbot quota reset +`); + + quota + .command('status') + .description("Show today's usage and the last 7 days") + .action(() => { + const usage = todayUsage(); + const history = loadQuota(); + + if (isJsonMode()) { + printJson({ + today: { + date: usage.date, + total: usage.total, + remaining: usage.remaining, + dailyLimit: DAILY_QUOTA, + endpoints: usage.endpoints, + }, + history: history.days, + }); + return; + } + + console.log(`Today (${usage.date}):`); + console.log(` Requests used: ${usage.total} / ${DAILY_QUOTA}`); + console.log(` Remaining budget: ${usage.remaining}`); + if (Object.keys(usage.endpoints).length === 0) { + console.log(' (no requests recorded yet)'); + } else { + console.log(' Endpoint breakdown:'); + const entries = Object.entries(usage.endpoints).sort((a, b) => b[1] - a[1]); + for (const [endpoint, count] of entries) { + console.log(` ${endpoint.padEnd(48)} ${count}`); + } + } + + const otherDays = Object.entries(history.days) + .filter(([d]) => d !== usage.date) + .sort((a, b) => b[0].localeCompare(a[0])); + if (otherDays.length > 0) { + console.log('\nRecent history:'); + for (const [date, bucket] of otherDays) { + console.log(` ${date} ${bucket.total}`); + } + } + }); + + quota + .command('reset') + .description('Delete the local quota counter file') + .action(() => { + resetQuota(); + if (isJsonMode()) { + printJson({ reset: true }); + } else { + console.log('Quota counter reset.'); + } + }); +} diff --git a/src/index.ts b/src/index.ts index dbd9d85..75c5cb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { registerScenesCommand } from './commands/scenes.js'; import { registerWebhookCommand } from './commands/webhook.js'; import { registerCompletionCommand } from './commands/completion.js'; import { registerMcpCommand } from './commands/mcp.js'; +import { registerQuotaCommand } from './commands/quota.js'; const program = new Command(); @@ -17,6 +18,10 @@ program .option('-v, --verbose', 'Log HTTP request/response details to stderr') .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)') .option('--timeout ', 'HTTP request timeout in milliseconds (default: 30000)') + .option('--retry-on-429 ', 'Max 429 retries before surfacing the error (default: 3)') + .option('--backoff ', 'Backoff strategy for retries: "linear" or "exponential" (default)') + .option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)') + .option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run') .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)') .showHelpAfterError('(run with --help to see usage)') .showSuggestionAfterError(); @@ -27,6 +32,7 @@ registerScenesCommand(program); registerWebhookCommand(program); registerCompletionCommand(program); registerMcpCommand(program); +registerQuotaCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/utils/flags.ts b/src/utils/flags.ts index b0343da..6364e70 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -34,3 +34,32 @@ export function getTimeout(): number { export function getConfigPath(): string | undefined { return getFlagValue('--config'); } + +/** + * Max 429 retries before surfacing the error. Default 3. `--no-retry` + * disables retries entirely; `--retry-on-429 ` overrides the count. + */ +export function getRetryOn429(): number { + if (process.argv.includes('--no-retry')) return 0; + const v = getFlagValue('--retry-on-429'); + if (v === undefined) return 3; + const n = Number(v); + if (!Number.isFinite(n) || n < 0) return 3; + return Math.floor(n); +} + +/** Backoff strategy for 429 retries. Default 'exponential'. */ +export function getBackoffStrategy(): 'linear' | 'exponential' { + const v = getFlagValue('--backoff'); + if (v === 'linear') return 'linear'; + return 'exponential'; +} + +/** + * Whether local quota counting is disabled. Quota counting is best-effort + * (see src/utils/quota.ts) — this lets scripts opt out entirely when even + * best-effort file I/O is unwelcome. + */ +export function isQuotaDisabled(): boolean { + return process.argv.includes('--no-quota'); +} diff --git a/src/utils/quota.ts b/src/utils/quota.ts new file mode 100644 index 0000000..b3121a6 --- /dev/null +++ b/src/utils/quota.ts @@ -0,0 +1,159 @@ +/** + * Local quota counter. Tracks the SwitchBot 10k/day request budget so the + * CLI (and any AI agent) can check "how many calls have I already burned?" + * without pinging the API. + * + * Shape (`~/.switchbot/quota.json`): + * { + * "days": { + * "2026-04-18": { + * "total": 42, + * "endpoints": { + * "GET /v1.1/devices": 3, + * "GET /v1.1/devices/:id/status": 27, + * "POST /v1.1/devices/:id/commands": 12 + * } + * } + * } + * } + * + * We keep the last 7 days to bound the file size and give a short-term + * trend. Writes are fire-and-forget — a failed write never breaks the + * actual API call. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +export const DAILY_QUOTA = 10_000; + +export interface DayBucket { + total: number; + endpoints: Record; +} + +export interface QuotaFile { + days: Record; +} + +const MAX_RETAINED_DAYS = 7; + +function quotaFilePath(): string { + return path.join(os.homedir(), '.switchbot', 'quota.json'); +} + +function today(now: Date = new Date()): string { + // Local date, not UTC — SwitchBot's quota window is loose but users + // reason about "today" in their own timezone. + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, '0'); + const d = String(now.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +function emptyFile(): QuotaFile { + return { days: {} }; +} + +export function loadQuota(): QuotaFile { + const file = quotaFilePath(); + if (!fs.existsSync(file)) return emptyFile(); + try { + const raw = fs.readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw) as QuotaFile; + if (!parsed || typeof parsed !== 'object' || !parsed.days) return emptyFile(); + return parsed; + } catch { + return emptyFile(); + } +} + +function saveQuota(data: QuotaFile): void { + const file = quotaFilePath(); + const dir = path.dirname(file); + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(data, null, 2)); + } catch { + // swallow: counting is best-effort, must not break a real API call + } +} + +function prune(data: QuotaFile): QuotaFile { + const keys = Object.keys(data.days).sort(); + if (keys.length <= MAX_RETAINED_DAYS) return data; + const keep = keys.slice(keys.length - MAX_RETAINED_DAYS); + const next: QuotaFile = { days: {} }; + for (const k of keep) next.days[k] = data.days[k]; + return next; +} + +/** + * Normalise a full URL into a SwitchBot-style endpoint pattern. The segment + * immediately after `devices` or `scenes` is collapsed to `:id` so we can + * bucket by endpoint shape rather than by specific deviceId/sceneId. + */ +export function normaliseEndpoint(method: string, url: string): string { + const m = (method || 'GET').toUpperCase(); + let pathOnly = url; + try { + const parsed = new URL(url); + pathOnly = parsed.pathname; + } catch { + const q = url.indexOf('?'); + if (q !== -1) pathOnly = url.slice(0, q); + } + const segments = pathOnly.split('/'); + for (let i = 0; i < segments.length - 1; i++) { + if (segments[i] === 'devices' || segments[i] === 'scenes') { + // Only collapse when the next segment looks like an id (not another + // API verb); the SwitchBot API uses lower-case keywords elsewhere, + // but guard against future collisions. + const next = segments[i + 1]; + if (next && next.length > 0) { + segments[i + 1] = ':id'; + } + } + } + return `${m} ${segments.join('/')}`; +} + +/** Record a single request. Bucketed by local-date + endpoint pattern. */ +export function recordRequest(method: string, url: string, now: Date = new Date()): void { + const key = today(now); + const endpoint = normaliseEndpoint(method, url); + const data = loadQuota(); + const bucket: DayBucket = data.days[key] ?? { total: 0, endpoints: {} }; + bucket.total += 1; + bucket.endpoints[endpoint] = (bucket.endpoints[endpoint] ?? 0) + 1; + data.days[key] = bucket; + saveQuota(prune(data)); +} + +export function resetQuota(): void { + const file = quotaFilePath(); + try { + if (fs.existsSync(file)) fs.unlinkSync(file); + } catch { + // ignore + } +} + +/** Return today's usage (convenience for `quota status`). */ +export function todayUsage(now: Date = new Date()): { + date: string; + total: number; + remaining: number; + endpoints: Record; +} { + const key = today(now); + const data = loadQuota(); + const bucket = data.days[key] ?? { total: 0, endpoints: {} }; + return { + date: key, + total: bucket.total, + remaining: Math.max(0, DAILY_QUOTA - bucket.total), + endpoints: { ...bucket.endpoints }, + }; +} diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..d5a3e98 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,67 @@ +/** + * Retry/backoff helpers for the axios client. Kept as pure functions so + * tests can pin attempt → delay without wall-clock sleeping. + * + * Backoff strategies: + * linear → 1s, 2s, 3s, ... (cap 30s) + * exponential → 1s, 2s, 4s, 8s, 16s (cap 30s) [default] + * + * If the server returns a `Retry-After` header we always prefer it over our + * own backoff — the API explicitly told us when to come back. + */ + +export type BackoffStrategy = 'linear' | 'exponential'; + +const BASE_MS = 1_000; +const MAX_MS = 30_000; + +/** + * Parse an HTTP `Retry-After` header. Supports both the seconds form + * ("Retry-After: 42") and the HTTP-date form ("Retry-After: Wed, 21 Oct + * 2015 07:28:00 GMT"). Returns the delay in ms, or undefined on garbage. + */ +export function parseRetryAfter(header: unknown, now: number = Date.now()): number | undefined { + if (typeof header !== 'string') return undefined; + const trimmed = header.trim(); + if (!trimmed) return undefined; + + // All-digits → seconds. + if (/^\d+$/.test(trimmed)) { + const seconds = Number(trimmed); + if (!Number.isFinite(seconds) || seconds < 0) return undefined; + return Math.min(seconds * 1000, MAX_MS); + } + + // HTTP-date. + const ts = Date.parse(trimmed); + if (!Number.isFinite(ts)) return undefined; + const delta = ts - now; + if (delta <= 0) return 0; + return Math.min(delta, MAX_MS); +} + +/** Compute the next backoff delay (ms) for a given attempt index (0-based). */ +export function computeBackoff(attempt: number, strategy: BackoffStrategy): number { + const safe = Math.max(0, attempt); + if (strategy === 'linear') { + return Math.min((safe + 1) * BASE_MS, MAX_MS); + } + // exponential + return Math.min(BASE_MS * Math.pow(2, safe), MAX_MS); +} + +/** Resolve the delay to use before the next retry, preferring Retry-After. */ +export function nextRetryDelayMs( + attempt: number, + strategy: BackoffStrategy, + retryAfterHeader: unknown, + now: number = Date.now() +): number { + const fromHeader = parseRetryAfter(retryAfterHeader, now); + if (fromHeader !== undefined) return fromHeader; + return computeBackoff(attempt, strategy); +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index c3b0697..3293bf7 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -18,6 +18,7 @@ const axiosMock = vi.hoisted(() => { request: { use: vi.fn() }, response: { use: vi.fn() }, }, + request: vi.fn(), }; return { default: { @@ -47,6 +48,22 @@ vi.mock('../../src/auth.js', () => ({ })), })); +// Quota recording is best-effort file I/O — mock it out so tests don't +// touch the real home directory and so we can assert on recorded calls. +const quotaMock = vi.hoisted(() => ({ + recordRequest: vi.fn(), +})); +vi.mock('../../src/utils/quota.js', () => ({ + recordRequest: quotaMock.recordRequest, + // The client doesn't import these, but export shims keep the module + // surface stable if other tests import it transitively. + loadQuota: vi.fn(), + resetQuota: vi.fn(), + todayUsage: vi.fn(), + normaliseEndpoint: vi.fn(), + DAILY_QUOTA: 10_000, +})); + import { createClient, ApiError, DryRunSignal } from '../../src/api/client.js'; describe('createClient', () => { @@ -349,3 +366,177 @@ describe('createClient — configurable globals', () => { } }); }); + +describe('createClient — 429 retry', () => { + const originalArgv = process.argv; + + beforeEach(() => { + captured.request = null; + captured.success = null; + captured.failure = null; + axiosMock.__instance.interceptors.request.use.mockReset(); + axiosMock.__instance.interceptors.response.use.mockReset(); + axiosMock.__instance.request.mockReset(); + axiosMock.default.create.mockClear(); + axiosMock.default.isAxiosError.mockReset(); + axiosMock.default.isAxiosError.mockReturnValue(true); + quotaMock.recordRequest.mockClear(); + + axiosMock.__instance.interceptors.request.use.mockImplementation((fn: RequestFn) => { + captured.request = fn; + }); + axiosMock.__instance.interceptors.response.use.mockImplementation( + (ok: ResponseOkFn, err: ResponseErrFn) => { + captured.success = ok; + captured.failure = err; + } + ); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + // Fast-forward setTimeout inside the retry delay. + vi.useFakeTimers(); + }); + + afterEach(() => { + process.argv = originalArgv; + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('retries a 429 response and resolves with the retried response', async () => { + process.argv = ['node', 'cli', 'devices', 'list']; + createClient(); + + const retriedResponse = { data: { statusCode: 100, body: { ok: true } } }; + axiosMock.__instance.request.mockResolvedValue(retriedResponse); + + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + }; + const error = { + response: { status: 429, headers: {} }, + config, + message: 'rate limited', + }; + + const pending = captured.failure!(error); + // The retry scheduler sleeps 1000ms on first attempt (exponential base). + await vi.advanceTimersByTimeAsync(1_000); + const result = await pending; + expect(result).toBe(retriedResponse); + expect(axiosMock.__instance.request).toHaveBeenCalledTimes(1); + expect(axiosMock.__instance.request).toHaveBeenCalledWith( + expect.objectContaining({ url: '/v1.1/devices' }) + ); + }); + + it('respects the server Retry-After header over the default backoff', async () => { + process.argv = ['node', 'cli', 'devices', 'list']; + createClient(); + + axiosMock.__instance.request.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + }; + const error = { + response: { status: 429, headers: { 'retry-after': '7' } }, + config, + message: 'rate limited', + }; + + const pending = captured.failure!(error); + // Retry-After=7 → should need >6000ms (not 1000ms). + await vi.advanceTimersByTimeAsync(1_000); + expect(axiosMock.__instance.request).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(6_000); + await pending; + expect(axiosMock.__instance.request).toHaveBeenCalledTimes(1); + }); + + it('gives up after --retry-on-429 attempts and throws a retryable ApiError', () => { + process.argv = ['node', 'cli', 'devices', 'list', '--retry-on-429', '2']; + createClient(); + + // Simulate the request having already been retried up to the cap — + // interceptor should skip the retry branch and throw the exhaustion + // error directly. This avoids re-entrant mocking. + const config = { + method: 'get', + baseURL: 'https://api.switch-bot.com', + url: '/v1.1/devices', + __retryCount: 2, + }; + + try { + captured.failure!({ + response: { status: 429, headers: {} }, + config, + message: 'rate limited', + }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).code).toBe(429); + expect((err as ApiError).retryable).toBe(true); + expect((err as ApiError).hint).toContain('quota status'); + } + expect(axiosMock.__instance.request).not.toHaveBeenCalled(); + }); + + it('--no-retry disables retries entirely', () => { + process.argv = ['node', 'cli', 'devices', 'list', '--no-retry']; + createClient(); + + const config = { method: 'get', baseURL: 'https://api.switch-bot.com', url: '/v1.1/devices' }; + try { + captured.failure!({ + response: { status: 429, headers: {} }, + config, + message: 'rate limited', + }); + expect.fail('should have thrown'); + } catch (err) { + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).code).toBe(429); + } + expect(axiosMock.__instance.request).not.toHaveBeenCalled(); + }); + + it('records a quota entry on a successful response', () => { + 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', + }, + }; + captured.success!(response); + expect(quotaMock.recordRequest).toHaveBeenCalledWith( + 'GET', + 'https://api.switch-bot.com/v1.1/devices' + ); + }); + + it('--no-quota skips quota recording', () => { + 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', + }, + }; + captured.success!(response); + expect(quotaMock.recordRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/quota.test.ts b/tests/commands/quota.test.ts new file mode 100644 index 0000000..3edf2b7 --- /dev/null +++ b/tests/commands/quota.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { registerQuotaCommand } from '../../src/commands/quota.js'; +import { runCli } from '../helpers/cli.js'; + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'switchbot-quota-cmd-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpRoot); +}); + +afterEach(() => { + vi.restoreAllMocks(); + try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +async function seedQuota(): Promise { + // Write a quota file with a couple of entries on today's date. + const { recordRequest } = await import('../../src/utils/quota.js'); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + recordRequest('POST', 'https://api.switch-bot.com/v1.1/devices/ABC/commands'); +} + +describe('quota command', () => { + it('status prints today usage + endpoint breakdown (human mode)', async () => { + await seedQuota(); + const result = await runCli(registerQuotaCommand, ['quota', 'status']); + expect(result.exitCode).toBeNull(); + const out = result.stdout.join('\n'); + expect(out).toMatch(/Today \(\d{4}-\d{2}-\d{2}\)/); + expect(out).toContain('Requests used:'); + expect(out).toContain('3 /'); + expect(out).toMatch(/GET \/v1\.1\/devices\s+2/); + expect(out).toMatch(/POST \/v1\.1\/devices\/:id\/commands\s+1/); + }); + + it('status --json returns structured payload', async () => { + await seedQuota(); + const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'status']); + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout[0]); + expect(parsed.today.total).toBe(3); + expect(parsed.today.remaining).toBe(10_000 - 3); + expect(parsed.today.dailyLimit).toBe(10_000); + expect(parsed.today.endpoints['GET /v1.1/devices']).toBe(2); + }); + + it('status says "no requests recorded yet" with an empty counter', async () => { + const result = await runCli(registerQuotaCommand, ['quota', 'status']); + expect(result.exitCode).toBeNull(); + expect(result.stdout.join('\n')).toMatch(/no requests recorded yet/); + }); + + it('reset deletes the quota file', async () => { + await seedQuota(); + const file = path.join(tmpRoot, '.switchbot', 'quota.json'); + expect(fs.existsSync(file)).toBe(true); + const result = await runCli(registerQuotaCommand, ['quota', 'reset']); + expect(result.exitCode).toBeNull(); + expect(fs.existsSync(file)).toBe(false); + expect(result.stdout.join('\n')).toContain('Quota counter reset'); + }); + + it('reset --json returns {reset:true}', async () => { + await seedQuota(); + const result = await runCli(registerQuotaCommand, ['--json', 'quota', 'reset']); + expect(result.exitCode).toBeNull(); + expect(JSON.parse(result.stdout[0])).toEqual({ reset: true }); + }); +}); diff --git a/tests/utils/quota.test.ts b/tests/utils/quota.test.ts new file mode 100644 index 0000000..6d9558d --- /dev/null +++ b/tests/utils/quota.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// Isolate quota.json to a tmp dir per-test. +let tmpRoot: string; +let originalHome: ReturnType; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'switchbot-quota-')); + originalHome = os.homedir(); + vi.spyOn(os, 'homedir').mockReturnValue(tmpRoot); +}); + +afterEach(() => { + vi.restoreAllMocks(); + try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +// Re-import inside each test so the spy is honored if quota.ts captures +// homedir() at call time (which it does). +async function importQuota() { + const mod = await import('../../src/utils/quota.js'); + return mod; +} + +describe('normaliseEndpoint', () => { + it('collapses long hex-like path segments to :id', async () => { + const { normaliseEndpoint } = await importQuota(); + expect( + normaliseEndpoint( + 'GET', + 'https://api.switch-bot.com/v1.1/devices/ABC123DEF456/status' + ) + ).toBe('GET /v1.1/devices/:id/status'); + }); + + it('collapses numeric segments to :id', async () => { + const { normaliseEndpoint } = await importQuota(); + expect(normaliseEndpoint('POST', 'https://api.switch-bot.com/v1.1/scenes/42/execute')).toBe( + 'POST /v1.1/scenes/:id/execute' + ); + }); + + it('uppercases the method', async () => { + const { normaliseEndpoint } = await importQuota(); + expect(normaliseEndpoint('get', 'https://api.switch-bot.com/v1.1/devices')).toBe( + 'GET /v1.1/devices' + ); + }); + + it('tolerates bare paths (no protocol/host)', async () => { + const { normaliseEndpoint } = await importQuota(); + expect(normaliseEndpoint('GET', '/v1.1/devices/XYZabc1234/status?x=1')).toBe( + 'GET /v1.1/devices/:id/status' + ); + }); + + it('leaves short non-id segments alone', async () => { + const { normaliseEndpoint } = await importQuota(); + expect(normaliseEndpoint('POST', 'https://api.switch-bot.com/v1.1/webhook/setup')).toBe( + 'POST /v1.1/webhook/setup' + ); + }); +}); + +describe('recordRequest + todayUsage', () => { + it('starts at zero when no file exists', async () => { + const { todayUsage, DAILY_QUOTA } = await importQuota(); + const u = todayUsage(); + expect(u.total).toBe(0); + expect(u.remaining).toBe(DAILY_QUOTA); + expect(u.endpoints).toEqual({}); + }); + + it('increments per call and writes to ~/.switchbot/quota.json', async () => { + const { recordRequest, todayUsage } = await importQuota(); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + recordRequest('POST', 'https://api.switch-bot.com/v1.1/devices/DEAD1234/commands'); + const u = todayUsage(); + expect(u.total).toBe(3); + expect(u.endpoints['GET /v1.1/devices']).toBe(2); + expect(u.endpoints['POST /v1.1/devices/:id/commands']).toBe(1); + + const file = path.join(tmpRoot, '.switchbot', 'quota.json'); + expect(fs.existsSync(file)).toBe(true); + }); + + it('resetQuota deletes the file', async () => { + const { recordRequest, resetQuota, todayUsage } = await importQuota(); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + resetQuota(); + expect(todayUsage().total).toBe(0); + }); + + it('retains at most 7 days of history', async () => { + const { recordRequest, loadQuota } = await importQuota(); + const base = new Date('2026-04-10T12:00:00'); + for (let i = 0; i < 10; i++) { + const d = new Date(base); + d.setDate(base.getDate() + i); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices', d); + } + const data = loadQuota(); + expect(Object.keys(data.days).length).toBe(7); + }); + + it('recovers gracefully from a corrupt quota.json', async () => { + const dir = path.join(tmpRoot, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'quota.json'), 'not valid json {'); + const { todayUsage, recordRequest } = await importQuota(); + expect(todayUsage().total).toBe(0); + recordRequest('GET', 'https://api.switch-bot.com/v1.1/devices'); + expect(todayUsage().total).toBe(1); + }); +}); diff --git a/tests/utils/retry.test.ts b/tests/utils/retry.test.ts new file mode 100644 index 0000000..01df3c6 --- /dev/null +++ b/tests/utils/retry.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { + parseRetryAfter, + computeBackoff, + nextRetryDelayMs, +} from '../../src/utils/retry.js'; + +describe('parseRetryAfter', () => { + it('returns undefined for non-string / empty input', () => { + expect(parseRetryAfter(undefined)).toBeUndefined(); + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter(42)).toBeUndefined(); + expect(parseRetryAfter('')).toBeUndefined(); + expect(parseRetryAfter(' ')).toBeUndefined(); + }); + + it('parses integer seconds into ms', () => { + expect(parseRetryAfter('5')).toBe(5_000); + expect(parseRetryAfter(' 10 ')).toBe(10_000); + expect(parseRetryAfter('0')).toBe(0); + }); + + it('caps very large integer values at 30 seconds', () => { + expect(parseRetryAfter('3600')).toBe(30_000); + }); + + it('parses HTTP-date form relative to now', () => { + const now = Date.parse('2026-04-18T10:00:00Z'); + const delay = parseRetryAfter('Sat, 18 Apr 2026 10:00:07 GMT', now); + expect(delay).toBe(7_000); + }); + + it('returns 0 when the HTTP-date is already in the past', () => { + const now = Date.parse('2026-04-18T10:00:00Z'); + const delay = parseRetryAfter('Sat, 18 Apr 2026 09:59:50 GMT', now); + expect(delay).toBe(0); + }); + + it('returns undefined for unparseable HTTP-date', () => { + expect(parseRetryAfter('not-a-date')).toBeUndefined(); + }); +}); + +describe('computeBackoff', () => { + it('linear: (attempt+1) * 1000ms, capped at 30s', () => { + expect(computeBackoff(0, 'linear')).toBe(1_000); + expect(computeBackoff(1, 'linear')).toBe(2_000); + expect(computeBackoff(9, 'linear')).toBe(10_000); + expect(computeBackoff(100, 'linear')).toBe(30_000); + }); + + it('exponential: 1s, 2s, 4s, 8s, ..., capped at 30s', () => { + expect(computeBackoff(0, 'exponential')).toBe(1_000); + expect(computeBackoff(1, 'exponential')).toBe(2_000); + expect(computeBackoff(2, 'exponential')).toBe(4_000); + expect(computeBackoff(3, 'exponential')).toBe(8_000); + expect(computeBackoff(4, 'exponential')).toBe(16_000); + expect(computeBackoff(5, 'exponential')).toBe(30_000); + expect(computeBackoff(10, 'exponential')).toBe(30_000); + }); + + it('clamps negative attempt indices to 0', () => { + expect(computeBackoff(-3, 'linear')).toBe(1_000); + expect(computeBackoff(-3, 'exponential')).toBe(1_000); + }); +}); + +describe('nextRetryDelayMs', () => { + it('prefers Retry-After over the computed backoff', () => { + expect(nextRetryDelayMs(0, 'exponential', '4')).toBe(4_000); + }); + + it('falls back to backoff when Retry-After is absent', () => { + expect(nextRetryDelayMs(2, 'exponential', undefined)).toBe(4_000); + expect(nextRetryDelayMs(2, 'linear', undefined)).toBe(3_000); + }); + + it('falls back to backoff when Retry-After is garbage', () => { + expect(nextRetryDelayMs(1, 'exponential', 'not-a-date')).toBe(2_000); + }); +}); From f9cfa19dadb92a17c0e730b00fb632d97be23766 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:15:18 +0800 Subject: [PATCH 08/26] chore: release v1.6.0 Step 7 of the AI-agent roadmap: transparent 429 backoff + quota awareness. Agents hitting the 10k/day ceiling now get automatic Retry-After-respecting retries and can introspect their own spend via `switchbot quota status` without an extra API call. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 24ffb90..b649295 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.5.0", + "version": "1.6.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From 45f176cc8ca8c6e990c9171c387274164ca8cb70 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:24:15 +0800 Subject: [PATCH 09/26] feat(catalog): load ~/.switchbot/catalog.json overlay; add catalog show/path/diff/refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents and power-users can now extend or patch the built-in device catalog without waiting on a CLI release by dropping a JSON array at ~/.switchbot/catalog.json. Overlay rules: - matching type replaces (partial merge — overlay keys win) - new type is appended (must supply category + commands) - { type: "X", remove: true } deletes the built-in New 'switchbot catalog' command exposes the overlay: path — where the file lives + load status show — effective/built-in/overlay catalog, with per-type zoom and --json diff — replaced/added/removed/ignored summary refresh — clear the in-process overlay cache and re-read getEffectiveCatalog() is wired through findCatalogEntry() and searchCatalog() so every lookup respects the overlay transparently. All existing consumers (devices types / commands / describe / batch / MCP search_catalog) inherit it without further changes. Tests: +32 (overlay merge semantics + command integration), suite 401 → 433. --- src/commands/catalog.ts | 292 +++++++++++++++++++++++++++++++++ src/commands/devices.ts | 9 +- src/devices/catalog.ts | 122 +++++++++++++- src/index.ts | 2 + src/lib/devices.ts | 7 +- tests/commands/catalog.test.ts | 228 +++++++++++++++++++++++++ tests/devices/catalog.test.ts | 156 +++++++++++++++++- 7 files changed, 806 insertions(+), 10 deletions(-) create mode 100644 src/commands/catalog.ts create mode 100644 tests/commands/catalog.test.ts diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..23ea9ec --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,292 @@ +import { Command } from 'commander'; +import { printTable, printJson, isJsonMode } from '../utils/output.js'; +import { + DEVICE_CATALOG, + findCatalogEntry, + getCatalogOverlayPath, + getEffectiveCatalog, + loadCatalogOverlay, + resetCatalogOverlayCache, + type DeviceCatalogEntry, +} from '../devices/catalog.js'; + +export function registerCatalogCommand(program: Command): void { + const catalog = program + .command('catalog') + .description('Inspect the built-in device catalog and any local overlay') + .addHelpText('after', ` +This CLI ships with a static catalog of known SwitchBot device types and +their commands (see 'switchbot devices types'). You can extend or override +it locally by dropping a JSON array at: + + ~/.switchbot/catalog.json + +Overlay rules (applied in order): + • Entry whose "type" matches a built-in replaces that entry's fields + (partial merge — overlay keys win, missing keys fall back to built-in). + • Entry with a new "type" is appended. New entries MUST supply both + "category" and "commands"; otherwise they are ignored silently. + • Entry like { "type": "X", "remove": true } deletes the built-in "X". + +Subcommands: + path Print the overlay file path and whether it exists + show Show the effective catalog (or one entry) + diff Show what the overlay changes vs the built-in catalog + refresh Re-read the overlay file (clears in-process cache) + +Examples: + $ switchbot catalog path + $ switchbot catalog show + $ switchbot catalog show "Smart Lock" + $ switchbot catalog show --source built-in + $ switchbot catalog diff + $ switchbot catalog refresh +`); + + catalog + .command('path') + .description('Print the overlay file path and whether it exists') + .action(() => { + const overlay = loadCatalogOverlay(); + if (isJsonMode()) { + printJson({ + path: overlay.path, + exists: overlay.exists, + valid: overlay.error === undefined, + error: overlay.error, + entryCount: overlay.entries.length, + }); + return; + } + console.log(`Overlay path: ${overlay.path}`); + console.log(`Exists: ${overlay.exists ? 'yes' : 'no'}`); + if (overlay.exists) { + if (overlay.error) { + console.log(`Status: invalid — ${overlay.error}`); + } else { + console.log(`Status: valid (${overlay.entries.length} entr${overlay.entries.length === 1 ? 'y' : 'ies'})`); + } + } else { + console.log(`(Create the file to extend the built-in catalog — see 'switchbot catalog --help'.)`); + } + }); + + catalog + .command('show') + .description("Show the effective catalog (or one entry). Defaults to 'effective' source.") + .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)') + .option('--source ', 'Which catalog to show: built-in | overlay | effective (default)', 'effective') + .action((typeParts: string[], options: { source: string }) => { + const source = options.source; + if (!['built-in', 'overlay', 'effective'].includes(source)) { + console.error(`Unknown --source "${source}". Expected: built-in, overlay, effective.`); + process.exit(2); + } + + let entries: DeviceCatalogEntry[]; + if (source === 'built-in') { + entries = DEVICE_CATALOG; + } else if (source === 'overlay') { + const overlay = loadCatalogOverlay(); + if (overlay.error) { + console.error(`Overlay file is invalid: ${overlay.error}`); + process.exit(1); + } + // Only entries that are full catalog entries (have category + commands) + // or that explicitly remove a built-in are rendered here. Partial + // overrides are hidden because they're not self-contained entries; + // use `catalog diff` to inspect them. + entries = overlay.entries.filter( + (e): e is DeviceCatalogEntry => + e.category !== undefined && e.commands !== undefined && !e.remove + ); + } else { + entries = getEffectiveCatalog(); + } + + const typeQuery = typeParts.join(' ').trim(); + if (typeQuery) { + const match = findCatalogEntry(typeQuery); + if (!match) { + console.error(`No device type matches "${typeQuery}".`); + process.exit(2); + } + if (Array.isArray(match)) { + console.error(`"${typeQuery}" matches multiple types. Be more specific:`); + for (const m of match) console.error(` • ${m.type}`); + process.exit(2); + } + // Restrict the match to the requested source if needed. + const picked = entries.find((e) => e.type === match.type); + if (!picked) { + console.error(`"${match.type}" exists in the effective catalog but not in source "${source}".`); + process.exit(2); + } + if (isJsonMode()) { + printJson(picked); + return; + } + renderEntry(picked); + return; + } + + if (isJsonMode()) { + printJson(entries); + return; + } + const rows = entries.map((e) => [ + e.type, + e.category, + String(e.commands.length), + (e.aliases ?? []).join(', ') || '—', + ]); + printTable(['type', 'category', 'commands', 'aliases'], rows); + console.log(`\nTotal: ${entries.length} device type(s) (source: ${source})`); + }); + + catalog + .command('diff') + .description('Show what the overlay replaces, adds, or removes vs the built-in catalog') + .action(() => { + const overlay = loadCatalogOverlay(); + const builtInByType = new Map(DEVICE_CATALOG.map((e) => [e.type, e])); + + const replaced: Array<{ type: string; changedKeys: string[] }> = []; + const added: string[] = []; + const removed: string[] = []; + const ignored: Array<{ type: string; reason: string }> = []; + + for (const e of overlay.entries) { + if (e.remove) { + if (builtInByType.has(e.type)) removed.push(e.type); + else ignored.push({ type: e.type, reason: 'remove: type not in built-in catalog' }); + continue; + } + const existing = builtInByType.get(e.type); + if (existing) { + const changed: string[] = []; + const overlayRec = e as unknown as Record; + const builtinRec = existing as unknown as Record; + for (const k of Object.keys(e)) { + if (k === 'type') continue; + if (JSON.stringify(overlayRec[k]) !== JSON.stringify(builtinRec[k])) { + changed.push(k); + } + } + replaced.push({ type: e.type, changedKeys: changed }); + } else if (e.category && e.commands) { + added.push(e.type); + } else { + ignored.push({ type: e.type, reason: 'new entry missing required fields (category and/or commands)' }); + } + } + + if (isJsonMode()) { + printJson({ + overlayPath: overlay.path, + overlayExists: overlay.exists, + overlayValid: overlay.error === undefined, + overlayError: overlay.error, + replaced, + added, + removed, + ignored, + }); + return; + } + + if (!overlay.exists) { + console.log(`No overlay at ${overlay.path} — effective catalog matches built-in.`); + return; + } + if (overlay.error) { + console.log(`Overlay at ${overlay.path} is invalid: ${overlay.error}`); + console.log('Effective catalog falls back to built-in.'); + return; + } + + console.log(`Overlay: ${overlay.path}`); + if (replaced.length === 0 && added.length === 0 && removed.length === 0 && ignored.length === 0) { + console.log('(overlay file is empty — effective catalog matches built-in)'); + return; + } + if (replaced.length > 0) { + console.log('\nReplaced:'); + for (const r of replaced) { + console.log(` ~ ${r.type} (keys: ${r.changedKeys.join(', ') || '—'})`); + } + } + if (added.length > 0) { + console.log('\nAdded:'); + for (const t of added) console.log(` + ${t}`); + } + if (removed.length > 0) { + console.log('\nRemoved:'); + for (const t of removed) console.log(` - ${t}`); + } + if (ignored.length > 0) { + console.log('\nIgnored:'); + for (const i of ignored) console.log(` ! ${i.type} — ${i.reason}`); + } + }); + + catalog + .command('refresh') + .description('Clear the in-process overlay cache (re-read on next use)') + .action(() => { + resetCatalogOverlayCache(); + const overlay = loadCatalogOverlay(); + if (isJsonMode()) { + printJson({ + refreshed: true, + path: overlay.path, + exists: overlay.exists, + valid: overlay.error === undefined, + error: overlay.error, + entryCount: overlay.entries.length, + }); + return; + } + if (!overlay.exists) { + console.log(`Overlay cache cleared. No overlay file at ${overlay.path} yet.`); + return; + } + if (overlay.error) { + console.log(`Overlay cache cleared, but the file is invalid: ${overlay.error}`); + return; + } + console.log(`Overlay cache cleared. Loaded ${overlay.entries.length} entr${overlay.entries.length === 1 ? 'y' : 'ies'} from ${overlay.path}.`); + }); + + // Note: getCatalogOverlayPath is imported so future subcommands can surface + // the path cheaply without a full overlay read; `path` currently uses the + // richer loadCatalogOverlay() result instead. + void getCatalogOverlayPath; +} + +function renderEntry(entry: DeviceCatalogEntry): void { + console.log(`Type: ${entry.type}`); + console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`); + if (entry.role) console.log(`Role: ${entry.role}`); + if (entry.readOnly) console.log(`ReadOnly: yes (status-only device, no control commands)`); + if (entry.aliases && entry.aliases.length > 0) { + console.log(`Aliases: ${entry.aliases.join(', ')}`); + } + if (entry.commands.length === 0) { + console.log('\nCommands: (none — status-only device)'); + } else { + console.log('\nCommands:'); + const rows = entry.commands.map((c) => { + const flags: string[] = []; + if (c.commandType === 'customize') flags.push('customize'); + if (c.destructive) flags.push('!destructive'); + const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command; + return [label, c.parameter, c.description]; + }); + printTable(['command', 'parameter', 'description'], rows); + } + if (entry.statusFields && entry.statusFields.length > 0) { + console.log('\nStatus fields (from "devices status"):'); + console.log(' ' + entry.statusFields.join(', ')); + } +} diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 7376ed5..65bc76a 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { printTable, printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js'; -import { DEVICE_CATALOG, findCatalogEntry, DeviceCatalogEntry } from '../devices/catalog.js'; +import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; import { fetchDeviceList, @@ -272,18 +272,19 @@ Examples: $ switchbot devices types --json `) .action(() => { + const catalog = getEffectiveCatalog(); if (isJsonMode()) { - printJson(DEVICE_CATALOG); + printJson(catalog); return; } - const rows = DEVICE_CATALOG.map((e) => [ + const rows = catalog.map((e) => [ e.type, e.category, String(e.commands.length), (e.aliases ?? []).join(', ') || '—', ]); printTable(['type', 'category', 'commands', 'aliases'], rows); - console.log(`\nTotal: ${DEVICE_CATALOG.length} device type(s)`); + console.log(`\nTotal: ${catalog.length} device type(s)`); }); // switchbot devices commands diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 0bda50f..519e4da 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -524,13 +524,14 @@ export function findCatalogEntry(query: string): DeviceCatalogEntry | DeviceCata if (!q) return null; const names = (e: DeviceCatalogEntry) => [e.type, ...(e.aliases ?? [])]; + const catalog = getEffectiveCatalog(); - const exact = DEVICE_CATALOG.find((e) => + const exact = catalog.find((e) => names(e).some((n) => n.toLowerCase() === q) ); if (exact) return exact; - const matches = DEVICE_CATALOG.filter((e) => + const matches = catalog.filter((e) => names(e).some((n) => n.toLowerCase().includes(q)) ); if (matches.length === 0) return null; @@ -565,3 +566,120 @@ export function suggestedActions(entry: DeviceCatalogEntry): Array<{ description: c.description, })); } + +// ---- Overlay loader ---------------------------------------------------- +// +// Users can drop a `~/.switchbot/catalog.json` file to override or extend +// the built-in catalog without waiting on a CLI release. The overlay is a +// list of DeviceCatalogEntry objects; each entry matches on `type`: +// - Entry with `type` matching a built-in replaces that built-in entry. +// - Entry with a new `type` is appended. +// - Entry with `{ type: "X", remove: true }` deletes the built-in. +// +// The overlay is loaded once per process and cached. Malformed JSON or +// files that don't match the expected shape are ignored (with a warning +// to stderr in verbose mode). + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +export interface CatalogOverlayEntry extends Partial { + type: string; + remove?: boolean; +} + +export interface OverlayLoadResult { + path: string; + exists: boolean; + entries: CatalogOverlayEntry[]; + error?: string; +} + +function overlayFilePath(): string { + return path.join(os.homedir(), '.switchbot', 'catalog.json'); +} + +export function getCatalogOverlayPath(): string { + return overlayFilePath(); +} + +/** Read the overlay file. Never throws — returns `error` on bad files. */ +export function loadCatalogOverlay(): OverlayLoadResult { + const file = overlayFilePath(); + if (!fs.existsSync(file)) { + return { path: file, exists: false, entries: [] }; + } + try { + const raw = fs.readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return { + path: file, + exists: true, + entries: [], + error: 'overlay must be a JSON array of device catalog entries', + }; + } + const entries: CatalogOverlayEntry[] = []; + for (const item of parsed) { + if (!item || typeof item !== 'object' || typeof item.type !== 'string') { + return { + path: file, + exists: true, + entries: [], + error: 'every overlay entry must be an object with a string `type`', + }; + } + entries.push(item as CatalogOverlayEntry); + } + return { path: file, exists: true, entries }; + } catch (err) { + return { + path: file, + exists: true, + entries: [], + error: err instanceof Error ? err.message : String(err), + }; + } +} + +let overlayCache: OverlayLoadResult | null = null; + +function overlayOnce(): OverlayLoadResult { + if (overlayCache === null) overlayCache = loadCatalogOverlay(); + return overlayCache; +} + +/** Clear the overlay cache (test helper; also useful for `catalog refresh`). */ +export function resetCatalogOverlayCache(): void { + overlayCache = null; +} + +/** Merge built-in catalog with the on-disk overlay. */ +export function getEffectiveCatalog(): DeviceCatalogEntry[] { + const overlay = overlayOnce(); + if (!overlay.entries.length) return DEVICE_CATALOG; + + const byType = new Map(); + for (const e of DEVICE_CATALOG) byType.set(e.type, e); + + for (const entry of overlay.entries) { + if (entry.remove) { + byType.delete(entry.type); + continue; + } + const existing = byType.get(entry.type); + if (existing) { + byType.set(entry.type, { ...existing, ...entry } as DeviceCatalogEntry); + } else if (entry.category && entry.commands) { + // New entry — require the fields the renderer needs. Missing fields + // would make the new entry crash later, so skip silently rather than + // ship half-valid data to the user. + byType.set(entry.type, entry as DeviceCatalogEntry); + } + } + + return Array.from(byType.values()); +} + diff --git a/src/index.ts b/src/index.ts index 75c5cb4..0fb8f36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { registerWebhookCommand } from './commands/webhook.js'; import { registerCompletionCommand } from './commands/completion.js'; import { registerMcpCommand } from './commands/mcp.js'; import { registerQuotaCommand } from './commands/quota.js'; +import { registerCatalogCommand } from './commands/catalog.js'; const program = new Command(); @@ -33,6 +34,7 @@ registerWebhookCommand(program); registerCompletionCommand(program); registerMcpCommand(program); registerQuotaCommand(program); +registerCatalogCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/lib/devices.ts b/src/lib/devices.ts index e90c514..2773deb 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -1,9 +1,9 @@ import type { AxiosInstance } from 'axios'; import { createClient } from '../api/client.js'; import { - DEVICE_CATALOG, findCatalogEntry, suggestedActions, + getEffectiveCatalog, type DeviceCatalogEntry, type CommandSpec, } from '../devices/catalog.js'; @@ -275,11 +275,12 @@ export function buildHubLocationMap( * Intended for MCP's `search_catalog` tool — not for dispatching commands. */ export function searchCatalog(query: string, limit = 20): DeviceCatalogEntry[] { + const catalog = getEffectiveCatalog(); const q = query.trim().toLowerCase(); - if (!q) return DEVICE_CATALOG.slice(0, limit); + if (!q) return catalog.slice(0, limit); const hits: DeviceCatalogEntry[] = []; - for (const entry of DEVICE_CATALOG) { + for (const entry of catalog) { const haystack = [entry.type, ...(entry.aliases ?? [])].map((s) => s.toLowerCase()); if (haystack.some((h) => h.includes(q))) { hits.push(entry); diff --git a/tests/commands/catalog.test.ts b/tests/commands/catalog.test.ts new file mode 100644 index 0000000..55b8958 --- /dev/null +++ b/tests/commands/catalog.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { runCli } from '../helpers/cli.js'; +import { registerCatalogCommand } from '../../src/commands/catalog.js'; +import { resetCatalogOverlayCache } from '../../src/devices/catalog.js'; + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'switchbot-catalog-cmd-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpRoot); + resetCatalogOverlayCache(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + resetCatalogOverlayCache(); + try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } +}); + +function writeOverlay(entries: unknown): void { + const dir = path.join(tmpRoot, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'catalog.json'), JSON.stringify(entries)); +} + +describe('catalog path', () => { + it('reports non-existent overlay with helpful hint', async () => { + const { stdout, exitCode } = await runCli(registerCatalogCommand, ['catalog', 'path']); + expect(exitCode).toBeNull(); + const out = stdout.join('\n'); + expect(out).toContain('Overlay path:'); + expect(out).toContain('Exists: no'); + expect(out).toContain('catalog.json'); + }); + + it('reports a valid overlay file', async () => { + writeOverlay([{ type: 'Bot', role: 'lighting' }]); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'path']); + const out = stdout.join('\n'); + expect(out).toContain('Exists: yes'); + expect(out).toContain('valid (1 entry)'); + }); + + it('reports an invalid overlay file with the error', async () => { + const dir = path.join(tmpRoot, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'catalog.json'), '{not json'); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'path']); + const out = stdout.join('\n'); + expect(out).toContain('Status: invalid'); + }); + + it('emits JSON when --json is passed', async () => { + writeOverlay([{ type: 'Bot' }]); + const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'path']); + const parsed = JSON.parse(stdout.join('\n')); + expect(parsed.exists).toBe(true); + expect(parsed.valid).toBe(true); + expect(parsed.entryCount).toBe(1); + }); +}); + +describe('catalog show', () => { + it('lists the effective catalog by default', async () => { + const { stdout, exitCode } = await runCli(registerCatalogCommand, ['catalog', 'show']); + expect(exitCode).toBeNull(); + const out = stdout.join('\n'); + expect(out).toContain('Bot'); + expect(out).toContain('Curtain'); + expect(out).toContain('source: effective'); + }); + + it('narrows to a single type by name', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'show', 'Bot']); + const out = stdout.join('\n'); + expect(out).toMatch(/Type:\s+Bot/); + expect(out).toContain('turnOn'); + }); + + it('supports multi-word type names unquoted', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'show', 'Smart', 'Lock']); + const out = stdout.join('\n'); + // Smart Lock alone matches multiple types via substring — the test exercises + // that multi-word args concatenate, then findCatalogEntry resolves the exact match. + expect(out).toMatch(/Smart Lock/); + }); + + it('--source built-in ignores overlay', async () => { + writeOverlay([{ type: 'Bot', remove: true }]); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'show', '--source', 'built-in']); + const out = stdout.join('\n'); + expect(out).toContain('Bot'); + expect(out).toContain('source: built-in'); + }); + + it('--source overlay shows only full overlay entries', async () => { + writeOverlay([ + { + type: 'Imaginary', + category: 'physical', + role: 'other', + commands: [{ command: 'ping', parameter: '—', description: 'Ping' }], + }, + { type: 'Bot', role: 'lighting' }, // partial override — excluded from overlay view + ]); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'show', '--source', 'overlay']); + const out = stdout.join('\n'); + expect(out).toContain('Imaginary'); + // Partial overlay entries are not listed here (they have no commands column to render). + expect(out).toContain('source: overlay'); + }); + + it('--source with unknown value exits with code 2', async () => { + const { stderr, exitCode } = await runCli(registerCatalogCommand, [ + 'catalog', + 'show', + '--source', + 'bogus', + ]); + expect(exitCode).toBe(2); + expect(stderr.join('\n')).toContain('Unknown --source'); + }); + + it('exits 2 when the type does not exist', async () => { + const { stderr, exitCode } = await runCli(registerCatalogCommand, ['catalog', 'show', 'Nonexistent']); + expect(exitCode).toBe(2); + expect(stderr.join('\n')).toContain('No device type matches'); + }); + + it('emits JSON array with --json', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show']); + const parsed = JSON.parse(stdout.join('\n')); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.find((e: { type: string }) => e.type === 'Bot')).toBeDefined(); + }); + + it('emits a single-entry JSON object when a type is given', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'show', 'Bot']); + const parsed = JSON.parse(stdout.join('\n')); + expect(parsed.type).toBe('Bot'); + }); +}); + +describe('catalog diff', () => { + it('reports no diff when no overlay exists', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'diff']); + const out = stdout.join('\n'); + expect(out).toContain('No overlay at'); + expect(out).toContain('matches built-in'); + }); + + it('reports replacements, additions, removals, and ignored entries', async () => { + writeOverlay([ + { type: 'Bot', role: 'lighting' }, // replace + { + type: 'New Thing', + category: 'physical', + role: 'other', + commands: [{ command: 'ping', parameter: '—', description: 'Ping' }], + }, // add + { type: 'Curtain', remove: true }, // remove + { type: 'Half Baked', role: 'other' }, // ignored (new, missing category+commands) + ]); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'diff']); + const out = stdout.join('\n'); + expect(out).toContain('Replaced:'); + expect(out).toContain('~ Bot'); + expect(out).toContain('role'); + expect(out).toContain('Added:'); + expect(out).toContain('+ New Thing'); + expect(out).toContain('Removed:'); + expect(out).toContain('- Curtain'); + expect(out).toContain('Ignored:'); + expect(out).toContain('! Half Baked'); + }); + + it('reports an invalid overlay', async () => { + const dir = path.join(tmpRoot, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'catalog.json'), '{malformed'); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'diff']); + const out = stdout.join('\n'); + expect(out).toContain('invalid'); + }); + + it('emits a structured JSON diff with --json', async () => { + writeOverlay([ + { type: 'Bot', role: 'lighting' }, + { type: 'Curtain', remove: true }, + ]); + const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'diff']); + const parsed = JSON.parse(stdout.join('\n')); + expect(parsed.replaced).toHaveLength(1); + expect(parsed.replaced[0].type).toBe('Bot'); + expect(parsed.replaced[0].changedKeys).toContain('role'); + expect(parsed.removed).toContain('Curtain'); + expect(parsed.added).toEqual([]); + }); +}); + +describe('catalog refresh', () => { + it('reports a successful refresh when no overlay is present', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'refresh']); + const out = stdout.join('\n'); + expect(out).toContain('Overlay cache cleared'); + expect(out).toContain('No overlay file'); + }); + + it('reports entry count after a successful refresh', async () => { + writeOverlay([{ type: 'Bot' }, { type: 'Curtain', remove: true }]); + const { stdout } = await runCli(registerCatalogCommand, ['catalog', 'refresh']); + const out = stdout.join('\n'); + expect(out).toContain('Loaded 2 entries'); + }); + + it('emits JSON with --json', async () => { + const { stdout } = await runCli(registerCatalogCommand, ['--json', 'catalog', 'refresh']); + const parsed = JSON.parse(stdout.join('\n')); + expect(parsed.refreshed).toBe(true); + }); +}); diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index c284c86..786dedc 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; import { DEVICE_CATALOG, findCatalogEntry, @@ -184,3 +187,154 @@ describe('devices/catalog', () => { }); }); }); + +describe('catalog overlay', () => { + let tmpRoot: string; + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'switchbot-catalog-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + try { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + + async function writeOverlay(entries: unknown): Promise { + const dir = path.join(tmpRoot, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'catalog.json'), JSON.stringify(entries)); + } + + async function freshImport() { + vi.resetModules(); + return await import('../../src/devices/catalog.js'); + } + + it('returns empty entries when overlay file is missing', async () => { + const { loadCatalogOverlay } = await freshImport(); + const result = loadCatalogOverlay(); + expect(result.exists).toBe(false); + expect(result.entries).toEqual([]); + expect(result.error).toBeUndefined(); + }); + + it('loads a valid overlay array', async () => { + await writeOverlay([{ type: 'Bot', role: 'other' }]); + const { loadCatalogOverlay } = await freshImport(); + const result = loadCatalogOverlay(); + expect(result.exists).toBe(true); + expect(result.entries).toEqual([{ type: 'Bot', role: 'other' }]); + expect(result.error).toBeUndefined(); + }); + + it('reports an error when overlay is not a JSON array', async () => { + await writeOverlay({ not: 'an array' }); + const { loadCatalogOverlay } = await freshImport(); + const result = loadCatalogOverlay(); + expect(result.exists).toBe(true); + expect(result.entries).toEqual([]); + expect(result.error).toMatch(/array/i); + }); + + it('reports an error when an overlay entry is missing string `type`', async () => { + await writeOverlay([{ role: 'other' }]); + const { loadCatalogOverlay } = await freshImport(); + const result = loadCatalogOverlay(); + expect(result.error).toMatch(/type/i); + expect(result.entries).toEqual([]); + }); + + it('reports a parse error for malformed JSON without throwing', async () => { + const dir = path.join(tmpRoot, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'catalog.json'), '{not valid json'); + const { loadCatalogOverlay } = await freshImport(); + const result = loadCatalogOverlay(); + expect(result.exists).toBe(true); + expect(result.entries).toEqual([]); + expect(result.error).toBeTruthy(); + }); + + it('overlay replaces fields on a matching built-in type (partial merge)', async () => { + await writeOverlay([{ type: 'Bot', role: 'lighting' }]); + const { getEffectiveCatalog } = await freshImport(); + const eff = getEffectiveCatalog(); + const bot = eff.find((e) => e.type === 'Bot'); + expect(bot?.role).toBe('lighting'); + // Other fields (commands, statusFields) still come from the built-in entry. + expect(bot?.commands.length).toBeGreaterThan(0); + expect(bot?.category).toBe('physical'); + }); + + it('overlay appends a new type when category+commands are supplied', async () => { + await writeOverlay([ + { + type: 'Imaginary Gadget', + category: 'physical', + role: 'other', + commands: [{ command: 'ping', parameter: '—', description: 'Ping it' }], + }, + ]); + const { getEffectiveCatalog } = await freshImport(); + const eff = getEffectiveCatalog(); + expect(eff.find((e) => e.type === 'Imaginary Gadget')).toBeDefined(); + }); + + it('overlay silently ignores new entries missing category or commands', async () => { + await writeOverlay([{ type: 'Half Baked', role: 'other' }]); + const { getEffectiveCatalog } = await freshImport(); + const eff = getEffectiveCatalog(); + expect(eff.find((e) => e.type === 'Half Baked')).toBeUndefined(); + }); + + it('overlay removes a built-in type when remove: true', async () => { + await writeOverlay([{ type: 'Bot', remove: true }]); + const { getEffectiveCatalog } = await freshImport(); + const eff = getEffectiveCatalog(); + expect(eff.find((e) => e.type === 'Bot')).toBeUndefined(); + // Other built-in types remain. + expect(eff.find((e) => e.type === 'Curtain')).toBeDefined(); + }); + + it('findCatalogEntry respects overlay (alias lookup on overlay-added type)', async () => { + await writeOverlay([ + { + type: 'Imaginary Gadget', + category: 'physical', + role: 'other', + aliases: ['ImagGadget'], + commands: [{ command: 'ping', parameter: '—', description: 'Ping' }], + }, + ]); + const { findCatalogEntry: find } = await freshImport(); + const match = find('ImagGadget'); + expect(Array.isArray(match)).toBe(false); + expect((match as { type: string }).type).toBe('Imaginary Gadget'); + }); + + it('resetCatalogOverlayCache re-reads the overlay file on next call', async () => { + await writeOverlay([{ type: 'Bot', role: 'lighting' }]); + const { getEffectiveCatalog, resetCatalogOverlayCache } = await freshImport(); + expect(getEffectiveCatalog().find((e) => e.type === 'Bot')?.role).toBe('lighting'); + + // Swap overlay contents on disk. + await writeOverlay([{ type: 'Bot', role: 'sensor' }]); + // Without refresh, cached snapshot is returned. + expect(getEffectiveCatalog().find((e) => e.type === 'Bot')?.role).toBe('lighting'); + resetCatalogOverlayCache(); + expect(getEffectiveCatalog().find((e) => e.type === 'Bot')?.role).toBe('sensor'); + }); + + it('DEVICE_CATALOG remains untouched by the overlay (no mutation)', async () => { + await writeOverlay([{ type: 'Bot', remove: true }]); + const { getEffectiveCatalog, DEVICE_CATALOG: builtin } = await freshImport(); + getEffectiveCatalog(); // force overlay application + expect(builtin.find((e) => e.type === 'Bot')).toBeDefined(); + }); +}); From 31b16e9de4f3d85e98bd4d8b963d5b651bff8ba2 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:24:22 +0800 Subject: [PATCH 10/26] chore: release v1.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b649295..9a85eff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.6.0", + "version": "1.7.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From 3603d8ef49e6e8c721aa7213bbee542f335ee087 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:33:08 +0800 Subject: [PATCH 11/26] feat(cache): add status cache + TTL gating; expose cache show/clear - Per-device status cache at ~/.switchbot/status.json, keyed by deviceId - List cache gains TTL check (listCacheAgeMs, isListCacheFresh); default 1h - New global flags --cache and --no-cache - Fresh list cache now short-circuits fetchDeviceList(); status cache short-circuits fetchDeviceStatus when TTL is enabled - New 'switchbot cache show' + 'cache clear --key list|status|all' - 40 new tests; full suite 462/462 green --- src/commands/cache.ts | 113 +++++++++++++++++++++++ src/devices/cache.ts | 159 +++++++++++++++++++++++++++++++++ src/index.ts | 4 + src/lib/devices.ts | 50 ++++++++++- src/utils/flags.ts | 50 +++++++++++ tests/commands/batch.test.ts | 11 +++ tests/commands/cache.test.ts | 149 ++++++++++++++++++++++++++++++ tests/commands/devices.test.ts | 15 ++++ tests/commands/mcp.test.ts | 10 +++ tests/devices/cache.test.ts | 142 +++++++++++++++++++++++++++++ 10 files changed, 702 insertions(+), 1 deletion(-) create mode 100644 src/commands/cache.ts create mode 100644 tests/commands/cache.test.ts diff --git a/src/commands/cache.ts b/src/commands/cache.ts new file mode 100644 index 0000000..99acfd1 --- /dev/null +++ b/src/commands/cache.ts @@ -0,0 +1,113 @@ +import { Command } from 'commander'; +import { printJson, isJsonMode } from '../utils/output.js'; +import { + clearCache, + clearStatusCache, + describeCache, + loadStatusCache, +} from '../devices/cache.js'; + +function formatAge(ms?: number): string { + if (ms === undefined) return '—'; + if (ms < 1000) return `${ms}ms`; + const s = Math.floor(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${s % 60}s`; + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; +} + +export function registerCacheCommand(program: Command): void { + const cache = program + .command('cache') + .description('Inspect and manage the local SwitchBot CLI caches') + .addHelpText('after', ` +Two caches live at ~/.switchbot/: + devices.json List of known deviceIds + metadata. Refreshed by every + 'devices list' call. Drives command validation and + helpful hints — keep around even if you don't use TTL. + status.json Per-device status bodies keyed by deviceId. Only written + when a status TTL is enabled (via --cache ). + +Cache modes (global flag, apply to any read): + --cache off | --no-cache disable cache reads + --cache auto (default) list cache on (1h TTL), status cache off + --cache 5m | --cache 1h enable both with the given TTL + +Subcommands: + show Show ages, entry counts, and file locations + clear Delete cache files (specify --key to scope) + +Examples: + $ switchbot cache show + $ switchbot --json cache show + $ switchbot cache clear # removes devices.json + status.json + $ switchbot cache clear --key status # removes only status.json + $ switchbot cache clear --key list # removes only devices.json +`); + + cache + .command('show') + .description('Summarize the cache files (paths, ages, entry counts)') + .action(() => { + const summary = describeCache(); + if (isJsonMode()) { + const statusCache = loadStatusCache(); + printJson({ + list: summary.list, + status: { + ...summary.status, + entries: Object.fromEntries( + Object.entries(statusCache.entries).map(([id, e]) => [id, { fetchedAt: e.fetchedAt }]) + ), + }, + }); + return; + } + + console.log('Device list cache (devices.json):'); + console.log(` Path: ${summary.list.path}`); + console.log(` Exists: ${summary.list.exists ? 'yes' : 'no'}`); + if (summary.list.exists) { + console.log(` Last update: ${summary.list.lastUpdated ?? '—'}`); + console.log(` Age: ${formatAge(summary.list.ageMs)}`); + console.log(` Devices: ${summary.list.deviceCount ?? 0}`); + } + + console.log('\nStatus cache (status.json):'); + console.log(` Path: ${summary.status.path}`); + console.log(` Exists: ${summary.status.exists ? 'yes' : 'no'}`); + console.log(` Entries: ${summary.status.entryCount}`); + if (summary.status.entryCount > 0) { + console.log(` Oldest: ${summary.status.oldestFetchedAt ?? '—'}`); + console.log(` Newest: ${summary.status.newestFetchedAt ?? '—'}`); + } + }); + + cache + .command('clear') + .description('Delete cache files') + .option('--key ', 'Which cache to clear: "list" | "status" | "all" (default)', 'all') + .action((options: { key: string }) => { + const key = options.key; + if (!['list', 'status', 'all'].includes(key)) { + console.error(`Unknown --key "${key}". Expected: list, status, all.`); + process.exit(2); + } + const cleared: string[] = []; + if (key === 'list' || key === 'all') { + clearCache(); + cleared.push('list'); + } + if (key === 'status' || key === 'all') { + clearStatusCache(); + cleared.push('status'); + } + if (isJsonMode()) { + printJson({ cleared }); + return; + } + console.log(`Cleared: ${cleared.join(', ')}`); + }); +} diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 03e4169..354a6bb 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -95,3 +95,162 @@ export function clearCache(): void { const file = cacheFilePath(); if (fs.existsSync(file)) fs.unlinkSync(file); } + +// ---- Device list freshness ------------------------------------------------- + +/** Age of the on-disk list cache in ms, or null if there is no cache. */ +export function listCacheAgeMs(now = Date.now()): number | null { + const cache = loadCache(); + if (!cache) return null; + const ts = Date.parse(cache.lastUpdated); + if (!Number.isFinite(ts)) return null; + return Math.max(0, now - ts); +} + +/** True when the on-disk list cache is present and younger than `ttlMs`. */ +export function isListCacheFresh(ttlMs: number, now = Date.now()): boolean { + if (!ttlMs || ttlMs <= 0) return false; + const age = listCacheAgeMs(now); + return age !== null && age < ttlMs; +} + +// ---- Status cache --------------------------------------------------------- +// +// Separate file from the device metadata cache because: +// - status is frequently invalidated, metadata is stable +// - clear commands should be able to scope to one or the other +// - the file can be deleted freely without losing the command-validation +// hints that the metadata cache provides +// +// Layout: { entries: { : { fetchedAt: ISO, body: } } } + +export interface CachedStatus { + fetchedAt: string; + body: Record; +} + +export interface StatusCache { + entries: Record; +} + +function statusCacheFilePath(): string { + const override = getConfigPath(); + const dir = override + ? path.dirname(path.resolve(override)) + : path.join(os.homedir(), '.switchbot'); + return path.join(dir, 'status.json'); +} + +export function loadStatusCache(): StatusCache { + const file = statusCacheFilePath(); + if (!fs.existsSync(file)) return { entries: {} }; + try { + const raw = fs.readFileSync(file, 'utf-8'); + const parsed = JSON.parse(raw) as StatusCache; + if (!parsed || typeof parsed.entries !== 'object' || parsed.entries === null) { + return { entries: {} }; + } + return parsed; + } catch { + return { entries: {} }; + } +} + +function saveStatusCache(cache: StatusCache): void { + try { + const file = statusCacheFilePath(); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 0o600 }); + } catch { + /* best-effort */ + } +} + +/** Read a status entry; returns null when missing or older than `ttlMs`. */ +export function getCachedStatus( + deviceId: string, + ttlMs: number, + now = Date.now() +): Record | null { + if (!ttlMs || ttlMs <= 0) return null; + const cache = loadStatusCache(); + const entry = cache.entries[deviceId]; + if (!entry) return null; + const ts = Date.parse(entry.fetchedAt); + if (!Number.isFinite(ts)) return null; + if (now - ts >= ttlMs) return null; + return entry.body; +} + +export function setCachedStatus( + deviceId: string, + body: Record, + now = new Date() +): void { + const cache = loadStatusCache(); + cache.entries[deviceId] = { + fetchedAt: now.toISOString(), + body, + }; + saveStatusCache(cache); +} + +export function clearStatusCache(): void { + const file = statusCacheFilePath(); + if (fs.existsSync(file)) fs.unlinkSync(file); +} + +/** Summary for `switchbot cache show`. */ +export interface CacheSummary { + list: { + path: string; + exists: boolean; + lastUpdated?: string; + ageMs?: number; + deviceCount?: number; + }; + status: { + path: string; + exists: boolean; + entryCount: number; + oldestFetchedAt?: string; + newestFetchedAt?: string; + }; +} + +export function describeCache(now = Date.now()): CacheSummary { + const listFile = cacheFilePath(); + const listCache = loadCache(); + const listExists = fs.existsSync(listFile); + const list: CacheSummary['list'] = { + path: listFile, + exists: listExists, + }; + if (listCache) { + list.lastUpdated = listCache.lastUpdated; + const ts = Date.parse(listCache.lastUpdated); + if (Number.isFinite(ts)) list.ageMs = Math.max(0, now - ts); + list.deviceCount = Object.keys(listCache.devices).length; + } + + const statusFile = statusCacheFilePath(); + const statusExists = fs.existsSync(statusFile); + const statusCache = loadStatusCache(); + const entries = Object.values(statusCache.entries); + const status: CacheSummary['status'] = { + path: statusFile, + exists: statusExists, + entryCount: entries.length, + }; + if (entries.length > 0) { + const sorted = entries + .map((e) => e.fetchedAt) + .filter((s): s is string => typeof s === 'string') + .sort(); + status.oldestFetchedAt = sorted[0]; + status.newestFetchedAt = sorted[sorted.length - 1]; + } + + return { list, status }; +} diff --git a/src/index.ts b/src/index.ts index 0fb8f36..9b5df1d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { registerCompletionCommand } from './commands/completion.js'; import { registerMcpCommand } from './commands/mcp.js'; import { registerQuotaCommand } from './commands/quota.js'; import { registerCatalogCommand } from './commands/catalog.js'; +import { registerCacheCommand } from './commands/cache.js'; const program = new Command(); @@ -23,6 +24,8 @@ program .option('--backoff ', 'Backoff strategy for retries: "linear" or "exponential" (default)') .option('--no-retry', 'Disable 429 retries entirely (equivalent to --retry-on-429 0)') .option('--no-quota', 'Disable the local ~/.switchbot/quota.json counter for this run') + .option('--cache ', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)') + .option('--no-cache', 'Disable cache reads (equivalent to --cache off)') .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)') .showHelpAfterError('(run with --help to see usage)') .showSuggestionAfterError(); @@ -35,6 +38,7 @@ registerCompletionCommand(program); registerMcpCommand(program); registerQuotaCommand(program); registerCatalogCommand(program); +registerCacheCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 2773deb..6fb9d88 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -7,7 +7,15 @@ import { type DeviceCatalogEntry, type CommandSpec, } from '../devices/catalog.js'; -import { getCachedDevice, updateCacheFromDeviceList } from '../devices/cache.js'; +import { + getCachedDevice, + updateCacheFromDeviceList, + loadCache, + isListCacheFresh, + getCachedStatus, + setCachedStatus, +} from '../devices/cache.js'; +import { getCacheMode } from '../utils/flags.js'; export interface Device { deviceId: string; @@ -75,6 +83,38 @@ export class CommandValidationError extends Error { /** Fetch the full device + IR remote inventory and refresh the local cache. */ export async function fetchDeviceList(client?: AxiosInstance): Promise { + // TTL-gated read: when the on-disk cache is younger than the configured + // list TTL, skip the API call and synthesize a DeviceListBody from the + // metadata cache. Only deviceId/deviceName/type/category survive the + // round-trip — other fields (familyName, roomID, hubDeviceId, etc.) are + // not cached. Callers that need those fields should pass --no-cache. + const mode = getCacheMode(); + if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) { + const cached = loadCache(); + if (cached) { + const deviceList: Device[] = []; + const infraredRemoteList: InfraredDevice[] = []; + for (const [deviceId, entry] of Object.entries(cached.devices)) { + if (entry.category === 'physical') { + deviceList.push({ + deviceId, + deviceName: entry.name, + deviceType: entry.type, + enableCloudService: false, + hubDeviceId: '', + }); + } else { + infraredRemoteList.push({ + deviceId, + deviceName: entry.name, + remoteType: entry.type, + hubDeviceId: '', + }); + } + } + return { deviceList, infraredRemoteList }; + } + } const c = client ?? createClient(); const res = await c.get<{ body: DeviceListBody }>('/v1.1/devices'); updateCacheFromDeviceList(res.data.body); @@ -86,10 +126,18 @@ export async function fetchDeviceStatus( deviceId: string, client?: AxiosInstance ): Promise> { + const mode = getCacheMode(); + if (mode.statusTtlMs > 0) { + const cached = getCachedStatus(deviceId, mode.statusTtlMs); + if (cached) return cached; + } const c = client ?? createClient(); const res = await c.get<{ body: Record }>( `/v1.1/devices/${deviceId}/status` ); + if (mode.statusTtlMs > 0) { + setCachedStatus(deviceId, res.data.body); + } return res.data.body; } diff --git a/src/utils/flags.ts b/src/utils/flags.ts index 6364e70..b233a4b 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -63,3 +63,53 @@ export function getBackoffStrategy(): 'linear' | 'exponential' { export function isQuotaDisabled(): boolean { return process.argv.includes('--no-quota'); } + +/** + * Cache TTL controls. Values: + * - `--no-cache` → disable cache for all reads + * - `--cache off` → same as `--no-cache` + * - `--cache auto` (default) → list cache on (1h), status cache off + * - `--cache 5m` | `--cache 1h` → enable both stores with the given TTL + * - numeric millisecond values are also accepted + */ +export interface CacheMode { + /** TTL for the device-list cache, in ms. 0/undefined = off. */ + listTtlMs: number; + /** TTL for the device-status cache, in ms. 0/undefined = off. */ + statusTtlMs: number; +} + +const DEFAULT_LIST_TTL_MS = 60 * 60 * 1000; + +function parseDurationToMs(v: string): number | null { + const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase()); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n < 0) return null; + const unit = m[2] ?? 'ms'; + switch (unit) { + case 'ms': return n; + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + default: return null; + } +} + +export function getCacheMode(): CacheMode { + if (process.argv.includes('--no-cache')) { + return { listTtlMs: 0, statusTtlMs: 0 }; + } + const v = getFlagValue('--cache'); + if (!v || v === 'auto') { + return { listTtlMs: DEFAULT_LIST_TTL_MS, statusTtlMs: 0 }; + } + if (v === 'off') { + return { listTtlMs: 0, statusTtlMs: 0 }; + } + const ms = parseDurationToMs(v); + if (ms === null || ms === 0) { + return { listTtlMs: DEFAULT_LIST_TTL_MS, statusTtlMs: 0 }; + } + return { listTtlMs: ms, statusTtlMs: ms }; +} diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 6ecd3c9..57d93aa 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -36,6 +36,16 @@ vi.mock('../../src/devices/cache.js', () => ({ updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, 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 }, + })), })); // Flags: dryRun toggleable per test. @@ -45,6 +55,7 @@ const flagsMock = vi.hoisted(() => ({ isVerbose: vi.fn(() => false), getTimeout: vi.fn(() => 30000), getConfigPath: vi.fn(() => undefined), + getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), })); vi.mock('../../src/utils/flags.js', () => flagsMock); diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts new file mode 100644 index 0000000..522bbdb --- /dev/null +++ b/tests/commands/cache.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +import { registerCacheCommand } from '../../src/commands/cache.js'; +import { + updateCacheFromDeviceList, + setCachedStatus, +} from '../../src/devices/cache.js'; +import { runCli } from '../helpers/cli.js'; + +let tmpHome: string; + +beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-cachecmd-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); +}); + +afterEach(() => { + vi.restoreAllMocks(); + try { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } catch { + // best-effort + } +}); + +const SAMPLE_BODY = { + deviceList: [ + { deviceId: 'BOT1', deviceName: 'Kitchen', deviceType: 'Bot' }, + { deviceId: 'BULB1', deviceName: 'Desk', deviceType: 'Color Bulb' }, + ], + infraredRemoteList: [ + { deviceId: 'IR1', deviceName: 'TV', remoteType: 'TV' }, + ], +}; + +describe('cache show', () => { + it('prints empty summaries on a fresh machine', async () => { + const result = await runCli(registerCacheCommand, ['cache', 'show']); + expect(result.exitCode).toBeNull(); + const out = result.stdout.join('\n'); + expect(out).toMatch(/Device list cache/); + expect(out).toMatch(/Exists:\s+no/); + expect(out).toMatch(/Status cache/); + expect(out).toMatch(/Entries:\s+0/); + }); + + it('reports list cache age + device count when populated', async () => { + updateCacheFromDeviceList(SAMPLE_BODY); + const result = await runCli(registerCacheCommand, ['cache', 'show']); + expect(result.exitCode).toBeNull(); + const out = result.stdout.join('\n'); + expect(out).toMatch(/Device list cache/); + expect(out).toMatch(/Exists:\s+yes/); + expect(out).toMatch(/Devices:\s+3/); + expect(out).toMatch(/Age:/); + }); + + it('reports status cache entry count + oldest/newest', async () => { + setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-01T00:00:00Z')); + setCachedStatus('BOT2', { power: 'off' }, new Date('2026-04-17T12:00:00Z')); + const result = await runCli(registerCacheCommand, ['cache', 'show']); + expect(result.exitCode).toBeNull(); + const out = result.stdout.join('\n'); + expect(out).toMatch(/Entries:\s+2/); + expect(out).toMatch(/Oldest:\s+2026-04-01T00:00:00\.000Z/); + expect(out).toMatch(/Newest:\s+2026-04-17T12:00:00\.000Z/); + }); + + it('--json emits a single machine-readable object', async () => { + updateCacheFromDeviceList(SAMPLE_BODY); + setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-17T12:00:00Z')); + const result = await runCli(registerCacheCommand, ['--json', 'cache', 'show']); + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout.join('\n')); + expect(parsed.list.exists).toBe(true); + expect(parsed.list.deviceCount).toBe(3); + expect(parsed.status.entryCount).toBe(1); + expect(parsed.status.entries.BOT1.fetchedAt).toBe('2026-04-17T12:00:00.000Z'); + // --json output should not leak the raw status body (only timestamps). + expect(parsed.status.entries.BOT1.body).toBeUndefined(); + }); +}); + +describe('cache clear', () => { + it('default clears both list and status caches', async () => { + updateCacheFromDeviceList(SAMPLE_BODY); + setCachedStatus('BOT1', { power: 'on' }); + + const listFile = path.join(tmpHome, '.switchbot', 'devices.json'); + const statusFile = path.join(tmpHome, '.switchbot', 'status.json'); + expect(fs.existsSync(listFile)).toBe(true); + expect(fs.existsSync(statusFile)).toBe(true); + + const result = await runCli(registerCacheCommand, ['cache', 'clear']); + expect(result.exitCode).toBeNull(); + expect(fs.existsSync(listFile)).toBe(false); + expect(fs.existsSync(statusFile)).toBe(false); + expect(result.stdout.join('\n')).toMatch(/Cleared:.*list.*status/); + }); + + it('--key list removes only devices.json', async () => { + updateCacheFromDeviceList(SAMPLE_BODY); + setCachedStatus('BOT1', { power: 'on' }); + + const listFile = path.join(tmpHome, '.switchbot', 'devices.json'); + const statusFile = path.join(tmpHome, '.switchbot', 'status.json'); + + const result = await runCli(registerCacheCommand, ['cache', 'clear', '--key', 'list']); + expect(result.exitCode).toBeNull(); + expect(fs.existsSync(listFile)).toBe(false); + expect(fs.existsSync(statusFile)).toBe(true); + }); + + it('--key status removes only status.json', async () => { + updateCacheFromDeviceList(SAMPLE_BODY); + setCachedStatus('BOT1', { power: 'on' }); + + const listFile = path.join(tmpHome, '.switchbot', 'devices.json'); + const statusFile = path.join(tmpHome, '.switchbot', 'status.json'); + + const result = await runCli(registerCacheCommand, ['cache', 'clear', '--key', 'status']); + expect(result.exitCode).toBeNull(); + expect(fs.existsSync(listFile)).toBe(true); + expect(fs.existsSync(statusFile)).toBe(false); + }); + + it('rejects unknown --key with exit 2', async () => { + const result = await runCli(registerCacheCommand, ['cache', 'clear', '--key', 'bogus']); + expect(result.exitCode).toBe(2); + expect(result.stderr.join('\n')).toMatch(/Unknown --key/); + }); + + it('--json reports which caches were cleared', async () => { + updateCacheFromDeviceList(SAMPLE_BODY); + const result = await runCli(registerCacheCommand, ['--json', 'cache', 'clear', '--key', 'list']); + expect(result.exitCode).toBeNull(); + const parsed = JSON.parse(result.stdout.join('\n')); + expect(parsed).toEqual({ cleared: ['list'] }); + }); + + it('is a no-op when files do not exist', async () => { + const result = await runCli(registerCacheCommand, ['cache', 'clear']); + expect(result.exitCode).toBeNull(); + expect(result.stdout.join('\n')).toMatch(/Cleared/); + }); +}); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 9ff1aab..065a450 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -102,7 +102,13 @@ const sampleBody = { }; describe('devices command', () => { + let tmpHome: string; beforeEach(() => { + // Redirect the cache dir to an ephemeral tmp path so the new 1h default + // list-cache TTL doesn't short-circuit the mocked HTTP client using a + // real ~/.switchbot/devices.json that might exist on the dev machine. + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-devtest-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); apiMock.__instance.get.mockReset(); apiMock.__instance.post.mockReset(); apiMock.createClient.mockReset(); @@ -110,6 +116,15 @@ describe('devices command', () => { apiMock.__instance.post.mockResolvedValue({ data: { body: {} } }); }); + afterEach(() => { + vi.restoreAllMocks(); + try { + fs.rmSync(tmpHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } + }); + // ===================================================================== // list // ===================================================================== diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 6684335..92c5f36 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -42,6 +42,16 @@ vi.mock('../../src/devices/cache.js', () => ({ updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, 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'; diff --git a/tests/devices/cache.test.ts b/tests/devices/cache.test.ts index fd091f0..b8eb579 100644 --- a/tests/devices/cache.test.ts +++ b/tests/devices/cache.test.ts @@ -8,6 +8,13 @@ import { getCachedDevice, updateCacheFromDeviceList, clearCache, + listCacheAgeMs, + isListCacheFresh, + loadStatusCache, + getCachedStatus, + setCachedStatus, + clearStatusCache, + describeCache, } from '../../src/devices/cache.js'; // Redirect the cache to a test-only temp directory by overriding both @@ -129,3 +136,138 @@ describe('device cache', () => { expect(fs.existsSync(path.join(tmpDir, '.switchbot', 'devices.json'))).toBe(false); }); }); + +describe('list cache TTL', () => { + it('listCacheAgeMs returns null when no cache file exists', () => { + expect(listCacheAgeMs()).toBeNull(); + }); + + it('listCacheAgeMs returns a non-negative age just after write', () => { + updateCacheFromDeviceList(sampleBody); + const age = listCacheAgeMs(); + expect(age).not.toBeNull(); + expect(age!).toBeGreaterThanOrEqual(0); + expect(age!).toBeLessThan(5_000); + }); + + it('listCacheAgeMs handles corrupt lastUpdated as null', () => { + const dir = path.join(tmpDir, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'devices.json'), + JSON.stringify({ lastUpdated: 'not-a-date', devices: {} }), + ); + expect(listCacheAgeMs()).toBeNull(); + }); + + it('isListCacheFresh: ttl=0 means never fresh', () => { + updateCacheFromDeviceList(sampleBody); + expect(isListCacheFresh(0)).toBe(false); + }); + + it('isListCacheFresh: fresh when age < ttl', () => { + updateCacheFromDeviceList(sampleBody); + expect(isListCacheFresh(60 * 60 * 1000)).toBe(true); + }); + + it('isListCacheFresh: stale when age >= ttl', () => { + updateCacheFromDeviceList(sampleBody); + // Rewrite the stored lastUpdated to something old. + const file = path.join(tmpDir, '.switchbot', 'devices.json'); + const raw = JSON.parse(fs.readFileSync(file, 'utf-8')); + raw.lastUpdated = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + fs.writeFileSync(file, JSON.stringify(raw)); + expect(isListCacheFresh(60 * 60 * 1000)).toBe(false); + }); +}); + +describe('status cache', () => { + it('loadStatusCache returns empty entries when no file exists', () => { + expect(loadStatusCache()).toEqual({ entries: {} }); + }); + + it('getCachedStatus returns null when ttl is 0 (cache disabled)', () => { + setCachedStatus('BOT1', { power: 'on' }); + expect(getCachedStatus('BOT1', 0)).toBeNull(); + }); + + it('setCachedStatus + getCachedStatus round-trip with live TTL', () => { + setCachedStatus('BOT1', { power: 'on', battery: 82 }); + const got = getCachedStatus('BOT1', 60_000); + expect(got).toEqual({ power: 'on', battery: 82 }); + }); + + it('getCachedStatus returns null for unknown deviceId', () => { + setCachedStatus('BOT1', { power: 'on' }); + expect(getCachedStatus('BOT2', 60_000)).toBeNull(); + }); + + it('getCachedStatus returns null when entry is older than ttl', () => { + setCachedStatus('BOT1', { power: 'on' }, new Date(Date.now() - 10 * 60_000)); + expect(getCachedStatus('BOT1', 60_000)).toBeNull(); + }); + + it('setCachedStatus overwrites the prior body for the same deviceId', () => { + setCachedStatus('BOT1', { power: 'on' }); + setCachedStatus('BOT1', { power: 'off', battery: 20 }); + expect(getCachedStatus('BOT1', 60_000)).toEqual({ power: 'off', battery: 20 }); + }); + + it('setCachedStatus preserves entries for other devices', () => { + setCachedStatus('BOT1', { power: 'on' }); + setCachedStatus('BOT2', { power: 'off' }); + expect(getCachedStatus('BOT1', 60_000)).toEqual({ power: 'on' }); + expect(getCachedStatus('BOT2', 60_000)).toEqual({ power: 'off' }); + }); + + it('clearStatusCache removes the file but keeps the device list cache', () => { + updateCacheFromDeviceList(sampleBody); + setCachedStatus('BOT1', { power: 'on' }); + const statusFile = path.join(tmpDir, '.switchbot', 'status.json'); + const listFile = path.join(tmpDir, '.switchbot', 'devices.json'); + expect(fs.existsSync(statusFile)).toBe(true); + clearStatusCache(); + expect(fs.existsSync(statusFile)).toBe(false); + expect(fs.existsSync(listFile)).toBe(true); + }); + + it('clearStatusCache is a no-op when no file exists', () => { + expect(() => clearStatusCache()).not.toThrow(); + }); + + it('loadStatusCache returns empty for malformed JSON', () => { + const dir = path.join(tmpDir, '.switchbot'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'status.json'), '{not json'); + expect(loadStatusCache()).toEqual({ entries: {} }); + }); +}); + +describe('describeCache', () => { + it('reports both caches as missing on a fresh machine', () => { + const s = describeCache(); + expect(s.list.exists).toBe(false); + expect(s.list.deviceCount).toBeUndefined(); + expect(s.status.exists).toBe(false); + expect(s.status.entryCount).toBe(0); + }); + + it('reports populated list cache age and device count', () => { + updateCacheFromDeviceList(sampleBody); + const s = describeCache(); + expect(s.list.exists).toBe(true); + expect(s.list.deviceCount).toBeGreaterThanOrEqual(3); + expect(typeof s.list.ageMs).toBe('number'); + expect(s.list.lastUpdated).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('reports oldest/newest status timestamps when populated', () => { + setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-01T00:00:00Z')); + setCachedStatus('BOT2', { power: 'off' }, new Date('2026-04-17T12:00:00Z')); + const s = describeCache(); + expect(s.status.exists).toBe(true); + expect(s.status.entryCount).toBe(2); + expect(s.status.oldestFetchedAt).toBe('2026-04-01T00:00:00.000Z'); + expect(s.status.newestFetchedAt).toBe('2026-04-17T12:00:00.000Z'); + }); +}); From ecc296cb93cb7ede092f3598a1eab6df39922609 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:33:16 +0800 Subject: [PATCH 12/26] chore: release v1.8.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a85eff..e27f563 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.7.0", + "version": "1.8.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From c63977564dc6eea8f7621486cb6a2036014ab5e7 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:38:32 +0800 Subject: [PATCH 13/26] feat(safety): guard destructive single commands with --yes The 'switchbot devices command ' path now refuses to send destructive catalog-annotated commands (Smart Lock unlock, Garage Door Opener turn*, Keypad createKey/deleteKey, ...) unless --yes is passed. --dry-run still previews without requiring --yes. Guard only fires when the local cache knows the device's type; unknown devices and --type=customize IR buttons pass through unchanged. Matches the destructive-command behavior already in 'devices batch' and MCP's send_command tool. --- src/commands/devices.ts | 40 ++++++++++- tests/commands/devices.test.ts | 117 ++++++++++++++++++++++++++++++--- 2 files changed, 146 insertions(+), 11 deletions(-) diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 65bc76a..ed655c0 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -8,11 +8,13 @@ import { executeCommand, describeDevice, validateCommand, + isDestructiveCommand, buildHubLocationMap, DeviceNotFoundError, type Device, } from '../lib/devices.js'; import { registerBatchCommand } from './batch.js'; +import { isDryRun } from '../utils/flags.js'; export function registerDevicesCommand(program: Command): void { const devices = program @@ -168,6 +170,7 @@ Examples: .argument('', 'Command name, e.g. turnOn, turnOff, setColor, setBrightness, setAll, startClean') .argument('[parameter]', 'Command parameter. Omit for commands like turnOn/turnOff (defaults to "default"). Format depends on the command (see below).') .option('--type ', 'Command type: "command" for built-in commands (default), "customize" for user-defined IR buttons', 'command') + .option('--yes', 'Confirm a destructive command (Smart Lock unlock, Garage open, …). --dry-run is always allowed without --yes.') .addHelpText('after', ` ──────────────────────────────────────────────────────────────────────── For the full list of commands a specific device supports — and their @@ -200,14 +203,20 @@ Common errors: 161 device offline (BLE devices need a Hub bridge) 171 hub offline +Safety: + Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff, + Keypad createKey/deleteKey, …) are blocked by default. Pass --yes to confirm, + or --dry-run to preview without sending. + Examples: $ switchbot devices command ABC123 turnOn $ switchbot devices command ABC123 setColor "255:0:0" $ switchbot devices command ABC123 setAll "26,1,3,on" $ switchbot devices command ABC123 startClean '{"action":"sweep","param":{"fanLevel":2,"times":1}}' $ switchbot devices command ABC123 "MyButton" --type customize + $ switchbot devices command unlock --yes `) - .action(async (deviceId: string, cmd: string, parameter: string | undefined, options: { type: string }) => { + .action(async (deviceId: string, cmd: string, parameter: string | undefined, options: { type: string; yes?: boolean }) => { const validation = validateCommand(deviceId, cmd, parameter, options.type); if (!validation.ok) { const err = validation.error; @@ -227,6 +236,35 @@ Examples: process.exit(2); } + const cachedForGuard = getCachedDevice(deviceId); + if ( + !options.yes && + !isDryRun() && + isDestructiveCommand(cachedForGuard?.type, cmd, options.type) + ) { + const typeLabel = cachedForGuard?.type ?? 'unknown'; + if (isJsonMode()) { + printJson({ + error: { + code: 'destructive_requires_confirm', + message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`, + hint: `Re-run with --yes to confirm, or --dry-run to preview without sending.`, + deviceId, + command: cmd, + deviceType: typeLabel, + }, + }); + } else { + console.error( + `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.` + ); + console.error( + `Re-run with --yes to confirm, or --dry-run to preview without sending.` + ); + } + process.exit(2); + } + try { // parameter may be a JSON object string (e.g. S10 startClean) or a plain string let parsedParam: unknown = parameter ?? 'default'; diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 065a450..d6af168 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -10,29 +10,28 @@ const clientInstance = vi.hoisted(() => ({ const apiMock = vi.hoisted(() => { const instance = { get: vi.fn(), post: vi.fn() }; + class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + } return { createClient: vi.fn(() => instance), __instance: instance, + DryRunSignal, }; }); vi.mock('../../src/api/client.js', () => ({ createClient: apiMock.createClient, ApiError: class ApiError extends Error { - constructor( - message: string, - public readonly code: number - ) { + 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'; - } - }, + DryRunSignal: apiMock.DryRunSignal, })); import { registerDevicesCommand } from '../../src/commands/devices.js'; @@ -1487,4 +1486,102 @@ describe('devices command', () => { ); }); }); + + // ===================================================================== + // command — destructive-command guard + // ===================================================================== + describe('command — destructive guard', () => { + let tmpDir: string; + const LOCK_ID = 'LOCK-1'; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-destructive-')); + vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); + updateCacheFromDeviceList({ + deviceList: [ + { deviceId: LOCK_ID, deviceName: 'Front Door', deviceType: 'Smart Lock' }, + { deviceId: 'BULB-1', deviceName: 'Lamp', deviceType: 'Color Bulb' }, + ], + infraredRemoteList: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('blocks Smart Lock unlock without --yes (exit 2, no POST)', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', LOCK_ID, 'unlock', + ]); + expect(res.exitCode).toBe(2); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + expect(res.stderr.join('\n')).toMatch(/destructive command "unlock"/); + expect(res.stderr.join('\n')).toMatch(/--yes/); + }); + + it('allows Smart Lock unlock when --yes is passed', async () => { + apiMock.__instance.post.mockResolvedValue({ + data: { statusCode: 100, body: {} }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', LOCK_ID, 'unlock', '--yes', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledWith( + `/v1.1/devices/${LOCK_ID}/commands`, + { command: 'unlock', parameter: 'default', commandType: 'command' } + ); + }); + + it('allows --dry-run without --yes (guard yields to dry-run preview)', async () => { + apiMock.__instance.post.mockImplementation(async () => { + throw new apiMock.DryRunSignal('POST', `/v1.1/devices/${LOCK_ID}/commands`); + }); + const res = await runCli(registerDevicesCommand, [ + '--dry-run', 'devices', 'command', LOCK_ID, 'unlock', + ]); + // The guard must NOT block with exit 2 — dry-run is always allowed. The + // stderr must not carry the destructive-block message either. + expect(res.exitCode).not.toBe(2); + expect(res.stderr.join('\n')).not.toMatch(/destructive command "unlock"/); + }); + + it('does not guard non-destructive commands (turnOn on a Bulb)', async () => { + apiMock.__instance.post.mockResolvedValue({ + data: { statusCode: 100, body: {} }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', 'BULB-1', 'turnOn', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + }); + + it('emits JSON error shape when --json is set and command is blocked', async () => { + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'command', LOCK_ID, 'unlock', + ]); + expect(res.exitCode).toBe(2); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.error.code).toBe('destructive_requires_confirm'); + expect(parsed.error.deviceId).toBe(LOCK_ID); + expect(parsed.error.command).toBe('unlock'); + expect(parsed.error.deviceType).toBe('Smart Lock'); + }); + + it('does not guard --type customize (user-defined IR buttons)', async () => { + apiMock.__instance.post.mockResolvedValue({ + data: { statusCode: 100, body: {} }, + }); + // Even if the button name happens to collide with a destructive command, + // customize IR buttons are opaque to the catalog and always allowed. + const res = await runCli(registerDevicesCommand, [ + 'devices', 'command', LOCK_ID, 'unlock', '--type', 'customize', + ]); + expect(res.exitCode).toBeNull(); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + }); + }); }); From 3d6286386570976429b1c5273d63be64b4b25a92 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:38:38 +0800 Subject: [PATCH 14/26] chore: release v1.9.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e27f563..4e268f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.8.0", + "version": "1.9.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From cb7dbd77266c1884424a7b89e469af14aba8128e Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:46:17 +0800 Subject: [PATCH 15/26] feat(observability): add 'devices watch' polling diff + 'events tail' local webhook receiver - devices watch polls status on an interval (default 30s, min 1s), emits JSONL field-level diffs; first tick seeds with from:null; --fields/--max/--include-unchanged; parallel per-device fetches with isolated error reporting; SIGINT/SIGTERM abort. - events tail starts a local HTTP receiver on --port/--path (default 3000 / '/'); optional --filter "deviceId=..,type=.." with comma-pair grammar that inspects body.context.deviceMac/deviceId/deviceType; JSONL or human output; --max stops after N matched events; rejects non-POST (405), unmatched paths (404), oversize bodies >1MB (413). - Lift parseDurationToMs out of flags.ts to share with watch. - Export startReceiver from events.ts for direct http-level testing. Co-Authored-By: Claude Opus 4.7 --- src/commands/devices.ts | 4 + src/commands/events.ts | 191 +++++++++++++++++++++++++++++ src/commands/watch.ts | 195 +++++++++++++++++++++++++++++ src/index.ts | 2 + src/utils/flags.ts | 2 + tests/commands/events.test.ts | 167 +++++++++++++++++++++++++ tests/commands/watch.test.ts | 222 ++++++++++++++++++++++++++++++++++ 7 files changed, 783 insertions(+) create mode 100644 src/commands/events.ts create mode 100644 src/commands/watch.ts create mode 100644 tests/commands/events.test.ts create mode 100644 tests/commands/watch.test.ts diff --git a/src/commands/devices.ts b/src/commands/devices.ts index ed655c0..e38cf59 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -14,6 +14,7 @@ import { type Device, } from '../lib/devices.js'; import { registerBatchCommand } from './batch.js'; +import { registerWatchCommand } from './watch.js'; import { isDryRun } from '../utils/flags.js'; export function registerDevicesCommand(program: Command): void { @@ -479,6 +480,9 @@ Examples: // switchbot devices batch ... registerBatchCommand(devices); + + // switchbot devices watch + registerWatchCommand(devices); } function renderCatalogEntry(entry: DeviceCatalogEntry): void { diff --git a/src/commands/events.ts b/src/commands/events.ts new file mode 100644 index 0000000..cb92d2e --- /dev/null +++ b/src/commands/events.ts @@ -0,0 +1,191 @@ +import { Command } from 'commander'; +import http from 'node:http'; +import { printJson, isJsonMode } from '../utils/output.js'; + +const DEFAULT_PORT = 3000; +const DEFAULT_PATH = '/'; +const MAX_BODY_BYTES = 1_000_000; + +interface EventRecord { + t: string; + remote: string; + path: string; + body: unknown; + matched: boolean; +} + +function matchFilter( + body: unknown, + filter: { deviceId?: string; type?: string } | null, +): boolean { + if (!filter) return true; + if (!body || typeof body !== 'object') return false; + const b = body as Record; + const ctx = (b.context ?? b) as Record; + if (filter.deviceId && ctx.deviceMac !== filter.deviceId && ctx.deviceId !== filter.deviceId) { + return false; + } + if (filter.type && ctx.deviceType !== filter.type) { + return false; + } + return true; +} + +function parseFilter(flag: string | undefined): { deviceId?: string; type?: string } | null { + if (!flag) return null; + const out: { deviceId?: string; type?: string } = {}; + for (const pair of flag.split(',')) { + const [k, v] = pair.split('=').map((s) => s.trim()); + if (!k || !v) continue; + if (k === 'deviceId') out.deviceId = v; + else if (k === 'type') out.type = v; + } + return out; +} + +export function startReceiver( + port: number, + pathMatch: string, + filter: { deviceId?: string; type?: string } | null, + onEvent: (ev: EventRecord) => void, +): http.Server { + const server = http.createServer((req, res) => { + if (req.method !== 'POST') { + res.statusCode = 405; + res.end('method not allowed'); + return; + } + if (req.url !== pathMatch && pathMatch !== '*') { + res.statusCode = 404; + res.end('not found'); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + let bailed = false; + req.on('data', (c: Buffer) => { + if (bailed) return; + size += c.length; + if (size > MAX_BODY_BYTES) { + bailed = true; + res.statusCode = 413; + res.setHeader('connection', 'close'); + res.end('payload too large'); + // Drop remaining upload without destroying the socket mid-flush. + req.on('data', () => {}); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (bailed) return; + const raw = Buffer.concat(chunks).toString('utf-8'); + let body: unknown = raw; + try { + body = JSON.parse(raw); + } catch { + // keep raw + } + const matched = matchFilter(body, filter); + onEvent({ + t: new Date().toISOString(), + remote: `${req.socket.remoteAddress ?? ''}:${req.socket.remotePort ?? ''}`, + path: req.url ?? '/', + body, + matched, + }); + res.statusCode = 204; + res.end(); + }); + }); + server.listen(port); + return server; +} + +export function registerEventsCommand(program: Command): void { + const events = program + .command('events') + .description('Subscribe to local webhook events forwarded by SwitchBot'); + + events + .command('tail') + .description('Run a local HTTP receiver and print incoming webhook events as JSONL') + .option('--port ', `Local port to listen on (default ${DEFAULT_PORT})`, String(DEFAULT_PORT)) + .option('--path

', `HTTP path to match (default "${DEFAULT_PATH}"; use "*" for all paths)`, DEFAULT_PATH) + .option('--filter ', 'Filter events, e.g. "deviceId=ABC123" or "type=Bot" (comma-separated)') + .option('--max ', 'Stop after N matching events (default: run until Ctrl-C)') + .addHelpText( + 'after', + ` +SwitchBot posts events to a single webhook URL configured via: + $ switchbot webhook setup https:/// + +'events tail' only runs the LOCAL receiver — it does not tunnel. Expose +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 } + +Filter grammar: comma-separated "key=value" pairs. Supported keys: + deviceId= match by context.deviceMac / context.deviceId + type= match by context.deviceType (e.g. "Bot", "WoMeter") + +Examples: + $ switchbot events tail --port 3000 + $ switchbot events tail --port 3000 --filter deviceId=ABC123 + $ switchbot events tail --filter 'type=WoMeter' --max 5 --json +`, + ) + .action(async (options: { port: string; path: string; filter?: string; max?: string }) => { + const port = Number(options.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + console.error(`Invalid --port "${options.port}". Must be 1..65535.`); + process.exit(2); + } + const maxMatched: number | null = options.max !== undefined ? Number(options.max) : null; + if (maxMatched !== null && (!Number.isFinite(maxMatched) || maxMatched < 1)) { + console.error(`Invalid --max "${options.max}". Must be a positive integer.`); + process.exit(2); + } + const filter = parseFilter(options.filter); + + let matchedCount = 0; + const ac = new AbortController(); + await new Promise((resolve, reject) => { + let server: http.Server | null = null; + try { + server = startReceiver(port, options.path, filter, (ev) => { + if (!ev.matched) return; + matchedCount++; + if (isJsonMode()) { + printJson(ev); + } else { + const when = new Date(ev.t).toLocaleTimeString(); + console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`); + } + if (maxMatched !== null && matchedCount >= maxMatched) { + ac.abort(); + } + }); + server.on('error', (err) => reject(err)); + } catch (err) { + reject(err); + return; + } + + const startMsg = `Listening on http://127.0.0.1:${port}${options.path} (Ctrl-C to stop)`; + if (!isJsonMode()) console.error(startMsg); + + const cleanup = () => { + server?.close(); + resolve(); + }; + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + ac.signal.addEventListener('abort', cleanup, { once: true }); + }); + }); +} diff --git a/src/commands/watch.ts b/src/commands/watch.ts new file mode 100644 index 0000000..c819560 --- /dev/null +++ b/src/commands/watch.ts @@ -0,0 +1,195 @@ +import { Command } from 'commander'; +import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { fetchDeviceStatus } from '../lib/devices.js'; +import { getCachedDevice } from '../devices/cache.js'; +import { parseDurationToMs } from '../utils/flags.js'; + +const DEFAULT_INTERVAL_MS = 30_000; +const MIN_INTERVAL_MS = 1_000; + +interface TickEvent { + t: string; + tick: number; + deviceId: string; + type?: string; + changed: Record; + error?: string; +} + +function diff( + prev: Record | undefined, + next: Record, + fields: string[] | null, +): Record { + const out: Record = {}; + const keys = fields ?? Object.keys(next); + for (const k of keys) { + const a = prev ? prev[k] : undefined; + const b = next[k]; + if (JSON.stringify(a) !== JSON.stringify(b)) { + out[k] = { from: prev ? a : null, to: b }; + } + } + return out; +} + +function formatHumanLine(ev: TickEvent): string { + const when = new Date(ev.t).toLocaleTimeString(); + const head = `[${when}] ${ev.deviceId}${ev.type ? ` (${ev.type})` : ''}`; + if (ev.error) return `${head}: error — ${ev.error}`; + const keys = Object.keys(ev.changed); + if (keys.length === 0) return `${head}: no changes`; + const pairs = keys + .map((k) => { + const { from, to } = ev.changed[k]; + if (from === null || from === undefined) return `${k}=${JSON.stringify(to)}`; + return `${k}: ${JSON.stringify(from)} → ${JSON.stringify(to)}`; + }) + .join(', '); + return `${head} ${pairs}`; +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const t = setTimeout(() => resolve(), ms); + const onAbort = () => { + clearTimeout(t); + resolve(); + }; + if (signal.aborted) { + clearTimeout(t); + resolve(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + } + }); +} + +export function registerWatchCommand(devices: Command): void { + devices + .command('watch') + .description('Poll device status on an interval and emit field-level changes (JSONL)') + .argument('', 'One or more deviceIds to watch') + .option( + '--interval ', + `Polling interval: "30s", "1m", "500ms", ... (default 30s, min ${MIN_INTERVAL_MS / 1000}s)`, + '30s', + ) + .option('--max ', 'Stop after N ticks (default: run until Ctrl-C)') + .option('--fields ', 'Only track a subset of status fields (default: all)') + .option('--include-unchanged', 'Emit a tick even when no field changed') + .addHelpText( + 'after', + ` +Each poll emits one JSON line per deviceId with the shape: + { "t": "", "tick": , "deviceId": "ID", "type": "Bot", + "changed": { "power": { "from": "off", "to": "on" } } } + +The very first poll has "from": null for every field (seed). + +Examples: + $ switchbot devices watch ABC123 --interval 10s + $ switchbot devices watch ABC123 --fields battery,power --interval 1m + $ switchbot devices watch ABC123 DEF456 --interval 30s --max 10 + $ switchbot devices watch ABC123 --json | jq 'select(.changed.power)' +`, + ) + .action( + async ( + deviceIds: string[], + options: { + interval: string; + max?: string; + fields?: string; + includeUnchanged?: boolean; + }, + ) => { + const parsed = parseDurationToMs(options.interval); + if (parsed === null || parsed < MIN_INTERVAL_MS) { + console.error( + `Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`, + ); + process.exit(2); + } + const intervalMs = parsed; + + let maxTicks: number | null = null; + if (options.max !== undefined) { + const n = Number(options.max); + if (!Number.isFinite(n) || n < 1) { + console.error(`Invalid --max "${options.max}". Must be a positive integer.`); + process.exit(2); + } + maxTicks = Math.floor(n); + } + + const fields: string[] | null = options.fields + ? options.fields.split(',').map((s) => s.trim()).filter(Boolean) + : null; + + const ac = new AbortController(); + const onSig = () => ac.abort(); + process.on('SIGINT', onSig); + process.on('SIGTERM', onSig); + + try { + const prev = new Map>(); + let tick = 0; + while (!ac.signal.aborted) { + tick++; + const t = new Date().toISOString(); + // Poll all devices in parallel; one failure per device doesn't stop + // the others. + await Promise.all( + deviceIds.map(async (id) => { + const cached = getCachedDevice(id); + try { + const body = await fetchDeviceStatus(id); + const changed = diff(prev.get(id), body, fields); + prev.set(id, body); + if (Object.keys(changed).length === 0 && !options.includeUnchanged) { + return; + } + const ev: TickEvent = { + t, + tick, + deviceId: id, + type: cached?.type, + changed, + }; + if (isJsonMode()) { + // JSONL: one event per line (printJson with newline). + printJson(ev); + } else { + console.log(formatHumanLine(ev)); + } + } catch (err) { + const ev: TickEvent = { + t, + tick, + deviceId: id, + type: cached?.type, + changed: {}, + error: err instanceof Error ? err.message : String(err), + }; + if (isJsonMode()) { + printJson(ev); + } else { + console.error(formatHumanLine(ev)); + } + } + }), + ); + + if (maxTicks !== null && tick >= maxTicks) break; + await sleep(intervalMs, ac.signal); + } + } catch (err) { + handleError(err); + } finally { + process.off('SIGINT', onSig); + process.off('SIGTERM', onSig); + } + }, + ); +} diff --git a/src/index.ts b/src/index.ts index 9b5df1d..073f0a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { registerMcpCommand } from './commands/mcp.js'; import { registerQuotaCommand } from './commands/quota.js'; import { registerCatalogCommand } from './commands/catalog.js'; import { registerCacheCommand } from './commands/cache.js'; +import { registerEventsCommand } from './commands/events.js'; const program = new Command(); @@ -39,6 +40,7 @@ registerMcpCommand(program); registerQuotaCommand(program); registerCatalogCommand(program); registerCacheCommand(program); +registerEventsCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/utils/flags.ts b/src/utils/flags.ts index b233a4b..0c9c390 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -96,6 +96,8 @@ function parseDurationToMs(v: string): number | null { } } +export { parseDurationToMs }; + export function getCacheMode(): CacheMode { if (process.argv.includes('--no-cache')) { return { listTtlMs: 0, statusTtlMs: 0 }; diff --git a/tests/commands/events.test.ts b/tests/commands/events.test.ts new file mode 100644 index 0000000..1f60035 --- /dev/null +++ b/tests/commands/events.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import http from 'node:http'; +import { once } from 'node:events'; +import { AddressInfo } from 'node:net'; +import { startReceiver } from '../../src/commands/events.js'; + +async function postJson(port: number, path: string, body: unknown): Promise { + const payload = typeof body === 'string' ? body : JSON.stringify(body); + return new Promise((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port, + path, + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + }, + }, + (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode ?? 0)); + }, + ); + req.on('error', reject); + req.write(payload); + req.end(); + }); +} + +async function pickPort(): Promise { + const srv = http.createServer(); + srv.listen(0); + await once(srv, 'listening'); + const port = (srv.address() as AddressInfo).port; + await new Promise((r) => srv.close(() => r())); + return port; +} + +describe('events tail receiver', () => { + it('accepts POST and forwards parsed JSON body to the callback', async () => { + const port = await pickPort(); + const received: unknown[] = []; + const server = startReceiver(port, '/', null, (ev) => received.push(ev)); + + const status = await postJson(port, '/', { event: 'state-change', deviceId: 'BOT1' }); + expect(status).toBe(204); + + await new Promise((r) => server.close(() => r())); + + expect(received).toHaveLength(1); + const ev = received[0] as { path: string; matched: boolean; body: { event: string; deviceId: string } }; + expect(ev.path).toBe('/'); + expect(ev.matched).toBe(true); + expect(ev.body).toEqual({ event: 'state-change', deviceId: 'BOT1' }); + }); + + it('returns 405 for non-POST methods', async () => { + const port = await pickPort(); + const server = startReceiver(port, '/', null, () => {}); + const status = await new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: '/', method: 'GET' }, + (res) => { + res.resume(); + res.on('end', () => resolve(res.statusCode ?? 0)); + }, + ); + req.on('error', reject); + req.end(); + }); + await new Promise((r) => server.close(() => r())); + expect(status).toBe(405); + }); + + it('returns 404 for mismatched paths', async () => { + const port = await pickPort(); + const server = startReceiver(port, '/webhook', null, () => {}); + const status = await postJson(port, '/other', {}); + await new Promise((r) => server.close(() => r())); + expect(status).toBe(404); + }); + + it('accepts any path when matcher is "*"', async () => { + const port = await pickPort(); + const received: unknown[] = []; + const server = startReceiver(port, '*', null, (ev) => received.push(ev)); + await postJson(port, '/any/path/here', { hello: 'world' }); + await new Promise((r) => server.close(() => r())); + expect(received).toHaveLength(1); + }); + + it('keeps body as raw string when JSON parsing fails', async () => { + const port = await pickPort(); + const received: Array<{ body: unknown }> = []; + const server = startReceiver(port, '/', null, (ev) => received.push(ev as { body: unknown })); + await postJson(port, '/', '{not json'); + await new Promise((r) => server.close(() => r())); + expect(received[0].body).toBe('{not json'); + }); + + it('marks events as unmatched when deviceId filter does not match', async () => { + const port = await pickPort(); + const received: Array<{ matched: boolean }> = []; + const server = startReceiver( + port, + '/', + { deviceId: 'BOT1' }, + (ev) => received.push(ev as { matched: boolean }), + ); + await postJson(port, '/', { context: { deviceMac: 'BOT2', deviceType: 'Bot' } }); + await postJson(port, '/', { context: { deviceMac: 'BOT1', deviceType: 'Bot' } }); + await new Promise((r) => server.close(() => r())); + expect(received).toHaveLength(2); + expect(received[0].matched).toBe(false); + expect(received[1].matched).toBe(true); + }); + + it('type filter matches on context.deviceType', async () => { + const port = await pickPort(); + const received: Array<{ matched: boolean }> = []; + const server = startReceiver( + port, + '/', + { type: 'WoMeter' }, + (ev) => received.push(ev as { matched: boolean }), + ); + await postJson(port, '/', { context: { deviceMac: 'X1', deviceType: 'Bot' } }); + await postJson(port, '/', { context: { deviceMac: 'X2', deviceType: 'WoMeter' } }); + await new Promise((r) => server.close(() => r())); + expect(received[0].matched).toBe(false); + expect(received[1].matched).toBe(true); + }); + + it('rejects oversized bodies with 413', async () => { + const port = await pickPort(); + const server = startReceiver(port, '/', null, () => {}); + const big = 'x'.repeat(2_000_000); + const status = await new Promise((resolve) => { + const req = http.request( + { + host: '127.0.0.1', + port, + path: '/', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(big), + }, + }, + (res) => { + res.resume(); + const code = res.statusCode ?? 0; + res.on('end', () => resolve(code)); + res.on('close', () => resolve(code)); + }, + ); + // Server may RST the socket after writing 413; swallow and resolve via + // the response we already captured. + req.on('error', () => resolve(0)); + req.write(big, () => req.end()); + }); + await new Promise((r) => server.close(() => r())); + expect(status).toBe(413); + }); +}); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts new file mode 100644 index 0000000..89ff5b0 --- /dev/null +++ b/tests/commands/watch.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + }; +}); + +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'; + } + }, +})); + +const cacheMock = vi.hoisted(() => ({ + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + 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 }, + })), +})); + +const flagsMock = vi.hoisted(() => ({ + isDryRun: vi.fn(() => false), + isVerbose: vi.fn(() => false), + getTimeout: vi.fn(() => 30000), + getConfigPath: vi.fn(() => undefined), + getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), + parseDurationToMs: (v: string): number | null => { + const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase()); + if (!m) return null; + const n = Number(m[1]); + if (!Number.isFinite(n) || n < 0) return null; + const unit = m[2] ?? 'ms'; + switch (unit) { + case 'ms': return n; + case 's': return n * 1000; + case 'm': return n * 60 * 1000; + case 'h': return n * 60 * 60 * 1000; + default: return null; + } + }, +})); +vi.mock('../../src/utils/flags.js', () => flagsMock); + +import { registerDevicesCommand } from '../../src/commands/devices.js'; +import { runCli } from '../helpers/cli.js'; + +describe('devices watch', () => { + beforeEach(() => { + apiMock.__instance.get.mockReset(); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + cacheMock.getCachedDevice.mockClear(); + // Make sleep near-instant so --max exits the loop quickly. + }); + + it('rejects intervals below the 1s minimum with exit 2', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'watch', 'BOT1', '--interval', '500ms', '--max', '1', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Invalid --interval/); + }); + + it('rejects --max=0 with exit 2', async () => { + const res = await runCli(registerDevicesCommand, [ + 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '0', + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Invalid --max/); + }); + + it('emits one JSONL event per device on first tick with from:null (--max=1)', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get.mockResolvedValueOnce({ + data: { statusCode: 100, body: { power: 'on', battery: 90 } }, + }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', + ]); + + // 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]); + expect(ev.deviceId).toBe('BOT1'); + expect(ev.type).toBe('Bot'); + expect(ev.tick).toBe(1); + expect(ev.changed.power).toEqual({ from: null, to: 'on' }); + expect(ev.changed.battery).toEqual({ from: null, to: 90 }); + }); + + it('only emits changed fields on subsequent ticks', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'Kitchen', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90 } } }) + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'off', battery: 90 } } }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '1s', '--max', '2', + ]); + expect(res.exitCode).toBeNull(); + + const events = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l)); + expect(events).toHaveLength(2); + expect(events[0].tick).toBe(1); + // Tick 2 should only include the power change — battery stayed 90. + expect(events[1].tick).toBe(2); + expect(events[1].changed.power).toEqual({ from: 'on', to: 'off' }); + expect(events[1].changed.battery).toBeUndefined(); + }, 20_000); + + it('suppresses unchanged ticks unless --include-unchanged is passed', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on' } } }) + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on' } } }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '1s', '--max', '2', + ]); + expect(res.exitCode).toBeNull(); + + const events = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l)); + // Only tick 1 should have emitted (tick 2 had zero changes). + expect(events).toHaveLength(1); + expect(events[0].tick).toBe(1); + }, 20_000); + + it('honors --include-unchanged', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on' } } }) + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on' } } }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '1s', '--max', '2', '--include-unchanged', + ]); + expect(res.exitCode).toBeNull(); + + const events = res.stdout + .filter((l) => l.trim().startsWith('{')) + .map((l) => JSON.parse(l)); + expect(events).toHaveLength(2); + expect(Object.keys(events[1].changed)).toHaveLength(0); + }, 20_000); + + it('respects --fields (other fields are ignored in the diff)', async () => { + cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); + apiMock.__instance.get + .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90, temp: 22 } } }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', '--fields', 'power,battery', + ]); + expect(res.exitCode).toBeNull(); + + const ev = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{'))[0]); + expect(ev.changed.power).toBeDefined(); + expect(ev.changed.battery).toBeDefined(); + expect(ev.changed.temp).toBeUndefined(); + }); + + 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' }); + // Parallel Promise.all, order of .get calls is not guaranteed — make both + // calls deterministic by matching on URL. + apiMock.__instance.get.mockImplementation(async (url: string) => { + if (url.includes('BOT1')) throw new Error('boom'); + return { data: { statusCode: 100, body: { power: 'on' } } }; + }); + + const res = await runCli(registerDevicesCommand, [ + '--json', 'devices', 'watch', 'BOT1', 'BOT2', '--interval', '5s', '--max', '1', + ]); + expect(res.exitCode).toBeNull(); + + const events = [ + ...res.stdout.filter((l) => l.trim().startsWith('{')), + ...res.stderr.filter((l) => l.trim().startsWith('{')), + ].map((l) => JSON.parse(l)); + expect(events).toHaveLength(2); + const byId = Object.fromEntries(events.map((e) => [e.deviceId, e])); + expect(byId.BOT1.error).toMatch(/boom/); + expect(byId.BOT2.changed.power).toEqual({ from: null, to: 'on' }); + }); +}); From e25f88687c4a06b2dff286142e95badd735e64a0 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:46:24 +0800 Subject: [PATCH 16/26] chore: release v1.10.0 Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4e268f4..ace4c9b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.9.0", + "version": "1.10.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From e3c07bae3af3bf56836af83687cc5fb0dc2411a4 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:49:54 +0800 Subject: [PATCH 17/26] feat(profiles): add --profile + secret sources (--from-env-file, --from-op) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global --profile flag routes credentials to ~/.switchbot/profiles/.json. Absent --profile → legacy ~/.switchbot/config.json for full backward compat. - Resolution priority: --config > --profile > default. - config set-token now accepts --from-env-file (dotenv parser for SWITCHBOT_TOKEN/SWITCHBOT_SECRET; comments and quotes handled) and --from-op + --op-secret (shells out to the 1Password CLI). Positional token/secret are now optional when a --from-* source is used. - New config list-profiles subcommand (sorted, JSON-aware). - loadConfig missing-file hint is profile-aware. - Export listProfiles() and profileFilePath() for test/tooling reuse. Co-Authored-By: Claude Opus 4.7 --- src/commands/config.ts | 113 +++++++++++++++++++++++++++++++--- src/config.ts | 41 +++++++++--- src/index.ts | 1 + src/utils/flags.ts | 5 ++ tests/commands/config.test.ts | 73 +++++++++++++++++++++- tests/config.test.ts | 79 +++++++++++++++++++++++- 6 files changed, 288 insertions(+), 24 deletions(-) diff --git a/src/commands/config.ts b/src/commands/config.ts index d69bf78..9d5da6f 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,7 +1,35 @@ import { Command } from 'commander'; -import { saveConfig, showConfig } from '../config.js'; +import fs from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { saveConfig, showConfig, listProfiles } from '../config.js'; +import { isJsonMode, printJson } from '../utils/output.js'; import chalk from 'chalk'; +function parseEnvFile(file: string): { token?: string; secret?: string } { + const out: { token?: string; secret?: string } = {}; + const raw = fs.readFileSync(file, 'utf-8'); + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + if (key === 'SWITCHBOT_TOKEN') out.token = val; + else if (key === 'SWITCHBOT_SECRET') out.secret = val; + } + return out; +} + +function readFromOp(ref: string): string { + // 1Password CLI: `op read "op://vault/item/field"` → single line on stdout + const stdout = execFileSync('op', ['read', ref], { encoding: 'utf-8' }); + return stdout.trim(); +} + export function registerConfigCommand(program: Command): void { const config = program .command('config') @@ -9,7 +37,9 @@ export function registerConfigCommand(program: Command): void { .addHelpText('after', ` Credential priority: 1. Environment variables: SWITCHBOT_TOKEN and SWITCHBOT_SECRET - 2. File: ~/.switchbot/config.json (created by 'config set-token') + 2. --config (explicit file override) + 3. --profile → ~/.switchbot/profiles/.json + 4. ~/.switchbot/config.json (default) Obtain your token/secret from the SwitchBot mobile app: Profile → Preferences → Developer Options → Get Token @@ -17,18 +47,65 @@ Obtain your token/secret from the SwitchBot mobile app: config .command('set-token') - .description('Save token and secret to ~/.switchbot/config.json (mode 0600)') - .argument('', 'API token (long hex string from the SwitchBot app)') - .argument('', 'API client secret (hex string from the SwitchBot app)') + .description('Save token and secret (mode 0600). Use --profile to target a named profile.') + .argument('[token]', 'API token; omit when using --from-env-file / --from-op') + .argument('[secret]', 'API client secret; omit when using --from-env-file / --from-op') + .option('--from-env-file ', 'Read SWITCHBOT_TOKEN and SWITCHBOT_SECRET from a dotenv file') + .option('--from-op ', 'Read token via 1Password CLI (op read). Pair with --op-secret ') + .option('--op-secret ', '1Password reference for the secret, used with --from-op') .addHelpText('after', ` -Example: - $ switchbot config set-token 0123abcd... 9876ffff... +Examples: + $ switchbot config set-token + $ switchbot --profile work config set-token + $ switchbot config set-token --from-env-file ./.env + $ switchbot config set-token --from-op op://vault/switchbot/token --op-secret op://vault/switchbot/secret -Note: the file is written with mode 0600 so only your user can read it. +Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/.json. `) - .action((token: string, secret: string) => { + .action(async ( + tokenArg: string | undefined, + secretArg: string | undefined, + options: { fromEnvFile?: string; fromOp?: string; opSecret?: string }, + ) => { + let token = tokenArg; + let secret = secretArg; + + if (options.fromEnvFile) { + if (!fs.existsSync(options.fromEnvFile)) { + console.error(`--from-env-file: file not found: ${options.fromEnvFile}`); + process.exit(2); + } + const parsed = parseEnvFile(options.fromEnvFile); + token = token ?? parsed.token; + secret = secret ?? parsed.secret; + } + + if (options.fromOp) { + if (!options.opSecret) { + console.error('--from-op requires --op-secret for the secret reference.'); + process.exit(2); + } + try { + token = readFromOp(options.fromOp); + secret = readFromOp(options.opSecret); + } catch (err) { + console.error(`1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`); + console.error('Ensure the "op" CLI is installed and authenticated (op signin).'); + process.exit(1); + } + } + + if (!token || !secret) { + console.error('Missing token/secret. Provide positional arguments or use --from-env-file / --from-op.'); + process.exit(2); + } + saveConfig(token, secret); - console.log(chalk.green('✓ Credentials saved to ~/.switchbot/config.json')); + if (isJsonMode()) { + printJson({ ok: true, message: 'credentials saved' }); + } else { + console.log(chalk.green('✓ Credentials saved')); + } }); config @@ -37,4 +114,20 @@ Note: the file is written with mode 0600 so only your user can read it. .action(() => { showConfig(); }); + + config + .command('list-profiles') + .description('List named profiles under ~/.switchbot/profiles/') + .action(() => { + const profiles = listProfiles(); + if (isJsonMode()) { + printJson({ profiles }); + return; + } + if (profiles.length === 0) { + console.log('No profiles. Create one with: switchbot --profile config set-token ...'); + return; + } + for (const p of profiles) console.log(p); + }); } diff --git a/src/config.ts b/src/config.ts index 8b8513e..2a1d2c7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,21 +1,45 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { getConfigPath } from './utils/flags.js'; +import { getConfigPath, getProfile } from './utils/flags.js'; export interface SwitchBotConfig { token: string; secret: string; } -function configFilePath(): string { +/** + * Credential file resolution priority: + * 1. --config (absolute override — wins over everything) + * 2. --profile → ~/.switchbot/profiles/.json + * 3. default → ~/.switchbot/config.json + * + * Env SWITCHBOT_TOKEN+SWITCHBOT_SECRET still take priority inside loadConfig. + */ +export function configFilePath(): string { const override = getConfigPath(); if (override) return path.resolve(override); + const profile = getProfile(); + if (profile) { + return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`); + } return path.join(os.homedir(), '.switchbot', 'config.json'); } +export function profileFilePath(profile: string): string { + return path.join(os.homedir(), '.switchbot', 'profiles', `${profile}.json`); +} + +export function listProfiles(): string[] { + const dir = path.join(os.homedir(), '.switchbot', 'profiles'); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => f.slice(0, -5)) + .sort(); +} + export function loadConfig(): SwitchBotConfig { - // Environment variables take priority (useful for CI) const envToken = process.env.SWITCHBOT_TOKEN; const envSecret = process.env.SWITCHBOT_SECRET; if (envToken && envSecret) { @@ -24,10 +48,11 @@ export function loadConfig(): SwitchBotConfig { const file = configFilePath(); if (!fs.existsSync(file)) { - console.error( - 'No credentials configured. Please run: switchbot config set-token \n' + - 'Or set the SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.' - ); + const profile = getProfile(); + const hint = profile + ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token ` + : 'No credentials configured. Run: switchbot config set-token '; + console.error(`${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`); process.exit(1); } @@ -35,7 +60,7 @@ export function loadConfig(): SwitchBotConfig { const raw = fs.readFileSync(file, 'utf-8'); const cfg = JSON.parse(raw) as SwitchBotConfig; if (!cfg.token || !cfg.secret) { - console.error('Invalid config.json format. Please re-run: switchbot config set-token'); + console.error('Invalid config format. Please re-run: switchbot config set-token'); process.exit(1); } return cfg; diff --git a/src/index.ts b/src/index.ts index 073f0a5..843ae41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,7 @@ program .option('--cache ', 'Cache mode: "off" | "auto" (default: list 1h, status off) | duration like 5m, 1h, 30s (enables both stores)') .option('--no-cache', 'Disable cache reads (equivalent to --cache off)') .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)') + .option('--profile ', 'Use a named profile: ~/.switchbot/profiles/.json') .showHelpAfterError('(run with --help to see usage)') .showSuggestionAfterError(); diff --git a/src/utils/flags.ts b/src/utils/flags.ts index 0c9c390..835b70e 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -35,6 +35,11 @@ export function getConfigPath(): string | undefined { return getFlagValue('--config'); } +/** Named profile → ~/.switchbot/profiles/.json. */ +export function getProfile(): string | undefined { + return getFlagValue('--profile'); +} + /** * Max 429 retries before surfacing the error. Default 3. `--no-retry` * disables retries entirely; `--retry-on-429 ` overrides the count. diff --git a/tests/commands/config.test.ts b/tests/commands/config.test.ts index 9f554cc..37aec7b 100644 --- a/tests/commands/config.test.ts +++ b/tests/commands/config.test.ts @@ -1,8 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; const configMock = vi.hoisted(() => ({ saveConfig: vi.fn(), showConfig: vi.fn(), + listProfiles: vi.fn(() => [] as string[]), })); vi.mock('../../src/config.js', () => configMock); @@ -14,6 +18,8 @@ describe('config command', () => { beforeEach(() => { configMock.saveConfig.mockReset(); configMock.showConfig.mockReset(); + configMock.listProfiles.mockReset(); + configMock.listProfiles.mockReturnValue([]); }); describe('set-token', () => { @@ -23,16 +29,18 @@ describe('config command', () => { expect(res.stdout.join('\n')).toContain('Credentials saved'); }); - it('fails when token is missing (commander error)', async () => { + it('fails when token is missing (no positional, no --from-*)', async () => { const res = await runCli(registerConfigCommand, ['config', 'set-token']); expect(configMock.saveConfig).not.toHaveBeenCalled(); - expect(res.stderr.join('\n').toLowerCase()).toContain('missing required'); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n').toLowerCase()).toMatch(/missing token\/secret/); }); it('fails when secret is missing', async () => { const res = await runCli(registerConfigCommand, ['config', 'set-token', 'only-token']); expect(configMock.saveConfig).not.toHaveBeenCalled(); - expect(res.stderr.join('\n').toLowerCase()).toContain('missing required'); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n').toLowerCase()).toMatch(/missing token\/secret/); }); }); @@ -42,4 +50,63 @@ describe('config command', () => { expect(configMock.showConfig).toHaveBeenCalledTimes(1); }); }); + + describe('list-profiles', () => { + it('prints each profile on its own line', async () => { + configMock.listProfiles.mockReturnValue(['home', 'work']); + const res = await runCli(registerConfigCommand, ['config', 'list-profiles']); + expect(res.stdout.join('\n')).toContain('home'); + expect(res.stdout.join('\n')).toContain('work'); + }); + + it('prints a helpful message when no profiles exist', async () => { + configMock.listProfiles.mockReturnValue([]); + const res = await runCli(registerConfigCommand, ['config', 'list-profiles']); + expect(res.stdout.join('\n').toLowerCase()).toContain('no profiles'); + }); + + it('emits JSON with --json', async () => { + configMock.listProfiles.mockReturnValue(['home']); + const res = await runCli(registerConfigCommand, ['--json', 'config', 'list-profiles']); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(out.profiles).toEqual(['home']); + }); + }); + + describe('set-token --from-env-file', () => { + it('reads SWITCHBOT_TOKEN / SWITCHBOT_SECRET from a .env file', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbenv-')); + const envFile = path.join(dir, '.env'); + fs.writeFileSync( + envFile, + '# comment\nSWITCHBOT_TOKEN=env_tok_abc\nSWITCHBOT_SECRET="env_sec_xyz"\nUNRELATED=ignored\n', + ); + const res = await runCli(registerConfigCommand, [ + 'config', 'set-token', '--from-env-file', envFile, + ]); + expect(configMock.saveConfig).toHaveBeenCalledWith('env_tok_abc', 'env_sec_xyz'); + expect(res.stdout.join('\n')).toContain('Credentials saved'); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('fails with exit 2 when the env file does not exist', async () => { + const res = await runCli(registerConfigCommand, [ + 'config', 'set-token', '--from-env-file', '/nonexistent/path/.env', + ]); + expect(res.exitCode).toBe(2); + expect(configMock.saveConfig).not.toHaveBeenCalled(); + }); + + it('fails with exit 2 when env file has neither var', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbenv-')); + const envFile = path.join(dir, '.env'); + fs.writeFileSync(envFile, 'OTHER=foo\n'); + const res = await runCli(registerConfigCommand, [ + 'config', 'set-token', '--from-env-file', envFile, + ]); + expect(res.exitCode).toBe(2); + expect(configMock.saveConfig).not.toHaveBeenCalled(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 54573b3..180a1e1 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -10,6 +10,7 @@ const fsMock = vi.hoisted(() => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), mkdirSync: vi.fn(), + readdirSync: vi.fn(() => [] as string[]), })); const osMock = vi.hoisted(() => ({ homedir: vi.fn(() => '/fake/home'), @@ -18,7 +19,7 @@ const osMock = vi.hoisted(() => ({ vi.mock('node:fs', () => ({ default: fsMock, ...fsMock })); vi.mock('node:os', () => ({ default: osMock, ...osMock })); -import { loadConfig, saveConfig, showConfig } from '../src/config.js'; +import { loadConfig, saveConfig, showConfig, listProfiles } from '../src/config.js'; describe('config', () => { beforeEach(() => { @@ -28,6 +29,8 @@ describe('config', () => { fsMock.readFileSync.mockReset(); fsMock.writeFileSync.mockReset(); fsMock.mkdirSync.mockReset(); + fsMock.readdirSync.mockReset(); + fsMock.readdirSync.mockReturnValue([]); }); describe('loadConfig', () => { @@ -99,7 +102,7 @@ describe('config', () => { expect(() => loadConfig()).toThrow('__exit'); expect(exitSpy).toHaveBeenCalledWith(1); - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid config.json format')); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid config format')); }); it('exits(1) when JSON parses but secret is missing', () => { @@ -111,7 +114,7 @@ describe('config', () => { fsMock.readFileSync.mockReturnValue(JSON.stringify({ token: 'only-token' })); expect(() => loadConfig()).toThrow('__exit'); - expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid config.json format')); + expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('Invalid config format')); }); }); @@ -241,4 +244,74 @@ describe('config', () => { expect(output).toContain(path.resolve('/custom/cfg.json')); }); }); + + describe('--profile ', () => { + const originalArgv = process.argv; + afterEach(() => { + process.argv = originalArgv; + }); + + it('loadConfig reads ~/.switchbot/profiles/.json', () => { + process.argv = ['node', 'cli', '--profile', 'work']; + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue(JSON.stringify({ token: 'work-t', secret: 'work-s' })); + + const cfg = loadConfig(); + + expect(cfg).toEqual({ token: 'work-t', secret: 'work-s' }); + const readPath = fsMock.readFileSync.mock.calls[0][0] as string; + expect(readPath).toBe(path.join(FAKE_HOME, '.switchbot', 'profiles', 'work.json')); + }); + + it('saveConfig writes the profile file and creates profiles/ directory', () => { + process.argv = ['node', 'cli', '--profile', 'home']; + fsMock.existsSync.mockReturnValue(false); + + saveConfig('t', 's'); + + expect(fsMock.mkdirSync).toHaveBeenCalledWith( + path.join(FAKE_HOME, '.switchbot', 'profiles'), + { recursive: true }, + ); + const writePath = fsMock.writeFileSync.mock.calls[0][0] as string; + expect(writePath).toBe(path.join(FAKE_HOME, '.switchbot', 'profiles', 'home.json')); + }); + + it('loadConfig emits a profile-specific hint when the file is missing', () => { + process.argv = ['node', 'cli', '--profile', 'work']; + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + fsMock.existsSync.mockReturnValue(false); + + expect(() => loadConfig()).toThrow('__exit'); + const msg = errSpy.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(msg).toContain('profile "work"'); + expect(msg).toContain('--profile work'); + }); + + it('--config beats --profile when both are passed', () => { + process.argv = ['node', 'cli', '--config', '/override.json', '--profile', 'work']; + fsMock.existsSync.mockReturnValue(true); + fsMock.readFileSync.mockReturnValue(JSON.stringify({ token: 't', secret: 's' })); + + loadConfig(); + const readPath = fsMock.readFileSync.mock.calls[0][0] as string; + expect(path.resolve(readPath)).toBe(path.resolve('/override.json')); + }); + }); + + describe('listProfiles', () => { + it('returns [] when the profiles directory does not exist', () => { + fsMock.existsSync.mockReturnValue(false); + expect(listProfiles()).toEqual([]); + }); + + it('returns each .json file without extension, sorted', () => { + fsMock.existsSync.mockReturnValue(true); + fsMock.readdirSync.mockReturnValue(['work.json', 'home.json', 'README', 'lab.json']); + expect(listProfiles()).toEqual(['home', 'lab', 'work']); + }); + }); }); From 01523c0dfdf875ce319b3cffb11cd169d0fbcce8 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:49:54 +0800 Subject: [PATCH 18/26] chore: release v1.11.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ace4c9b..89609fe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.10.0", + "version": "1.11.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From 450f6d087c18893b4bf128bd3a741503e2f8359d Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:57:32 +0800 Subject: [PATCH 19/26] feat(ops): add devices explain, doctor, schema export, history/replay + audit log - 'devices explain ': one-shot agent-friendly summary combining metadata + live status + commands (with idempotent/destructive flags) + children (IR remotes bound to a Hub) + suggested actions + warnings. - 'doctor': self-check across Node version, credentials (env vs file), profiles, catalog, cache, quota, and clock. JSON-aware; exit 1 on fail. - 'schema export [--type ]': dump the full device catalog as JSON for prompt engineering / docs generation. Always emits JSON. - 'history show' / 'history replay ': read the audit JSONL log and re-run a recorded command. Global '--audit-log [path]' enables append-on-execute in executeCommand (dry-run commands still logged; errors are captured with result:error). - writeAudit() is best-effort: failures never break the actual command. Co-Authored-By: Claude Opus 4.7 --- src/commands/devices.ts | 4 + src/commands/doctor.ts | 156 +++++++++++++++++++++++++++++ src/commands/explain.ts | 174 +++++++++++++++++++++++++++++++++ src/commands/history.ts | 101 +++++++++++++++++++ src/commands/schema.ts | 84 ++++++++++++++++ src/index.ts | 7 ++ src/lib/devices.ts | 36 ++++++- src/utils/audit.ts | 51 ++++++++++ src/utils/flags.ts | 16 +++ tests/commands/batch.test.ts | 2 + tests/commands/doctor.test.ts | 81 +++++++++++++++ tests/commands/history.test.ts | 132 +++++++++++++++++++++++++ tests/commands/schema.test.ts | 46 +++++++++ tests/commands/watch.test.ts | 2 + tests/utils/audit.test.ts | 99 +++++++++++++++++++ 15 files changed, 986 insertions(+), 5 deletions(-) create mode 100644 src/commands/doctor.ts create mode 100644 src/commands/explain.ts create mode 100644 src/commands/history.ts create mode 100644 src/commands/schema.ts create mode 100644 src/utils/audit.ts create mode 100644 tests/commands/doctor.test.ts create mode 100644 tests/commands/history.test.ts create mode 100644 tests/commands/schema.test.ts create mode 100644 tests/utils/audit.test.ts diff --git a/src/commands/devices.ts b/src/commands/devices.ts index e38cf59..699bc23 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -15,6 +15,7 @@ import { } from '../lib/devices.js'; import { registerBatchCommand } from './batch.js'; import { registerWatchCommand } from './watch.js'; +import { registerExplainCommand } from './explain.js'; import { isDryRun } from '../utils/flags.js'; export function registerDevicesCommand(program: Command): void { @@ -483,6 +484,9 @@ Examples: // switchbot devices watch registerWatchCommand(devices); + + // switchbot devices explain + registerExplainCommand(devices); } function renderCatalogEntry(entry: DeviceCatalogEntry): void { diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..d77b571 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,156 @@ +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 { getEffectiveCatalog } from '../devices/catalog.js'; +import { configFilePath, listProfiles } from '../config.js'; +import { describeCache } from '../devices/cache.js'; + +interface Check { + name: string; + status: 'ok' | 'warn' | 'fail'; + detail: string; +} + +async function checkCredentials(): Promise { + const envOk = Boolean(process.env.SWITCHBOT_TOKEN && process.env.SWITCHBOT_SECRET); + if (envOk) return { name: 'credentials', status: 'ok', detail: 'env: SWITCHBOT_TOKEN + SWITCHBOT_SECRET' }; + const file = configFilePath(); + if (!fs.existsSync(file)) { + return { + name: 'credentials', + status: 'fail', + detail: `No env vars and no config at ${file}. Run 'switchbot config set-token'.`, + }; + } + try { + const raw = fs.readFileSync(file, 'utf-8'); + const cfg = JSON.parse(raw); + if (!cfg.token || !cfg.secret) { + return { name: 'credentials', status: 'fail', detail: `Config ${file} missing token/secret.` }; + } + return { name: 'credentials', status: 'ok', detail: `file: ${file}` }; + } catch (err) { + return { + name: 'credentials', + status: 'fail', + detail: `Unreadable config ${file}: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +function checkProfiles(): Check { + const dir = path.join(os.homedir(), '.switchbot', 'profiles'); + if (!fs.existsSync(dir)) { + return { name: 'profiles', status: 'ok', detail: 'no profile dir (default profile only)' }; + } + const profiles = listProfiles(); + return { + name: 'profiles', + status: 'ok', + detail: profiles.length ? `found ${profiles.length}: ${profiles.join(', ')}` : 'profile dir empty', + }; +} + +function checkClockSkew(): Check { + const now = Date.now(); + const drift = now - Math.floor(now / 1000) * 1000; + // HMAC signing uses ms timestamps — we can't detect remote skew without a + // round-trip, but we can flag if the local clock has NTP issues via the + // classic "jumps back" pattern. Best-effort: just report local time. + const iso = new Date().toISOString(); + return { name: 'clock', status: 'ok', detail: `local time ${iso} (drift check needs API round-trip)` }; + void drift; +} + +function checkCatalog(): Check { + const catalog = getEffectiveCatalog(); + const missingRole = catalog.filter((e) => !e.role).length; + if (catalog.length === 0) { + return { name: 'catalog', status: 'fail', detail: 'catalog empty — package corrupt?' }; + } + const status = missingRole > 0 ? 'warn' : 'ok'; + return { + name: 'catalog', + status, + detail: `${catalog.length} types loaded${missingRole > 0 ? `, ${missingRole} missing role` : ''}`, + }; +} + +function checkCache(): Check { + try { + const info = describeCache(); + const parts: string[] = []; + parts.push(info.list.exists ? `list: ${info.list.path}` : 'list: (none)'); + parts.push(info.status.exists ? `status: ${info.status.entryCount} entries` : 'status: (none)'); + return { name: 'cache', status: 'ok', detail: parts.join(' | ') }; + } catch (err) { + return { name: 'cache', status: 'warn', detail: `cache inspect failed: ${err instanceof Error ? err.message : String(err)}` }; + } +} + +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)' }; + } + 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'` }; + } +} + +function checkNodeVersion(): Check { + const major = Number(process.versions.node.split('.')[0]); + if (Number.isFinite(major) && major < 18) { + return { name: 'node', status: 'fail', detail: `Node ${process.versions.node} — minimum is 18` }; + } + return { name: 'node', status: 'ok', detail: `Node ${process.versions.node}` }; +} + +export function registerDoctorCommand(program: Command): void { + program + .command('doctor') + .description('Self-check: credentials, catalog, cache, quota, profiles, Node version') + .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. + +Examples: + $ switchbot doctor + $ switchbot --json doctor | jq '.checks[] | select(.status != "ok")' +`) + .action(async () => { + const checks: Check[] = [ + checkNodeVersion(), + await checkCredentials(), + checkProfiles(), + checkCatalog(), + checkCache(), + checkQuotaFile(), + checkClockSkew(), + ]; + const summary = { + ok: checks.filter((c) => c.status === 'ok').length, + warn: checks.filter((c) => c.status === 'warn').length, + fail: checks.filter((c) => c.status === 'fail').length, + }; + const overallFail = summary.fail > 0; + + if (isJsonMode()) { + printJson({ overall: overallFail ? 'fail' : summary.warn > 0 ? 'warn' : 'ok', summary, checks }); + } else { + for (const c of checks) { + const icon = c.status === 'ok' ? '✓' : c.status === 'warn' ? '!' : '✗'; + console.log(`${icon} ${c.name.padEnd(12)} ${c.detail}`); + } + console.log(''); + console.log(`${summary.ok} ok, ${summary.warn} warn, ${summary.fail} fail`); + } + if (overallFail) process.exit(1); + }); +} diff --git a/src/commands/explain.ts b/src/commands/explain.ts new file mode 100644 index 0000000..66b59ac --- /dev/null +++ b/src/commands/explain.ts @@ -0,0 +1,174 @@ +import { Command } from 'commander'; +import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { + describeDevice, + fetchDeviceList, + DeviceNotFoundError, + type Device, + type InfraredDevice, +} from '../lib/devices.js'; +import type { DescribeResult } from '../lib/devices.js'; + +interface ExplainResult { + deviceId: string; + type: string; + category: 'physical' | 'ir'; + name: string; + role: string | null; + readOnly: boolean; + location?: { family?: string; room?: string }; + liveStatus?: Record; + commands: Array<{ command: string; parameter: string; idempotent?: boolean; destructive?: boolean }>; + statusFields: string[]; + children: Array<{ deviceId: string; name: string; type: string }>; + suggestedActions: Array<{ command: string; parameter?: string; description: string }>; + warnings: string[]; +} + +function deviceName(d: Device | InfraredDevice): string { + return d.deviceName; +} + +export function registerExplainCommand(devices: Command): void { + devices + .command('explain') + .description('One-shot device summary: metadata + capabilities + live status + children (for Hubs)') + .argument('', 'Device ID to explain') + .option('--no-live', 'Skip the live status API call (catalog-only output)') + .addHelpText('after', ` +'explain' is the agent-friendly sibling of 'describe'. It combines: + - metadata (id, name, type, category, role) + - live status (unless --no-live) + - commands with idempotent/destructive flags + - children (for Hub devices: IR remotes bound to this hub) + - suggested actions (pre-baked common usages) + - warnings (deprecated types, missing cloud service, etc.) + +Examples: + $ switchbot devices explain + $ switchbot --json devices explain | jq '.commands[] | select(.destructive)' + $ switchbot devices explain --no-live +`) + .action(async (deviceId: string, options: { live?: boolean }) => { + try { + const wantLive = options.live !== false; + const desc: DescribeResult = await describeDevice(deviceId, { live: wantLive }); + + const warnings: string[] = []; + if (desc.isPhysical && !(desc.device as Device).enableCloudService) { + warnings.push('Cloud service disabled on this device — commands will fail.'); + } + if (!desc.catalog) { + warnings.push(`No catalog entry for type "${desc.typeName}". Commands cannot be validated offline.`); + } + + let children: ExplainResult['children'] = []; + if (desc.catalog?.role === 'hub') { + const body = await fetchDeviceList(); + children = body.infraredRemoteList + .filter((ir) => ir.hubDeviceId === deviceId) + .map((ir) => ({ deviceId: ir.deviceId, name: ir.deviceName, type: ir.remoteType })); + } + + 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, + })) + : []; + const statusFields = caps && 'statusFields' in caps ? caps.statusFields : []; + const liveStatus = caps && 'liveStatus' in caps ? caps.liveStatus : undefined; + + const location: ExplainResult['location'] = desc.isPhysical + ? { + family: (desc.device as Device).familyName, + room: (desc.device as Device).roomName ?? undefined, + } + : desc.inheritedLocation + ? { family: desc.inheritedLocation.family, room: desc.inheritedLocation.room } + : undefined; + + const result: ExplainResult = { + deviceId, + type: desc.typeName, + category: desc.isPhysical ? 'physical' : 'ir', + name: deviceName(desc.device), + role: desc.catalog?.role ?? null, + readOnly: desc.catalog?.readOnly ?? false, + location, + liveStatus, + commands, + statusFields, + children, + suggestedActions: desc.suggestedActions, + warnings, + }; + + if (isJsonMode()) { + printJson(result); + return; + } + printHuman(result); + } catch (err) { + if (err instanceof DeviceNotFoundError) { + if (isJsonMode()) { + printJson({ error: { code: 'device_not_found', message: err.message, deviceId } }); + } else { + console.error(err.message); + } + process.exit(1); + } + handleError(err); + } + }); +} + +function printHuman(r: ExplainResult): void { + console.log(`# ${r.name} (${r.deviceId})`); + console.log(`type: ${r.type} [${r.category}${r.role ? ', ' + r.role : ''}${r.readOnly ? ', read-only' : ''}]`); + if (r.location?.family || r.location?.room) { + const loc = [r.location?.family, r.location?.room].filter(Boolean).join(' / '); + console.log(`location: ${loc}`); + } + if (r.warnings.length) { + console.log('warnings:'); + for (const w of r.warnings) console.log(` ! ${w}`); + } + if (r.liveStatus && !('error' in r.liveStatus)) { + console.log('live status:'); + for (const [k, v] of Object.entries(r.liveStatus)) { + console.log(` ${k}: ${JSON.stringify(v)}`); + } + } else if (r.liveStatus && 'error' in r.liveStatus) { + console.log(`live status: error — ${r.liveStatus.error}`); + } + if (r.commands.length) { + console.log('commands:'); + for (const c of r.commands) { + const flags = [c.idempotent && 'idempotent', c.destructive && 'destructive'] + .filter(Boolean) + .join(', '); + const suffix = flags ? ` [${flags}]` : ''; + console.log(` ${c.command}${c.parameter !== '—' ? ` <${c.parameter}>` : ''}${suffix}`); + } + } + if (r.statusFields.length) { + console.log(`status fields: ${r.statusFields.join(', ')}`); + } + if (r.children.length) { + console.log(`children (${r.children.length}):`); + for (const c of r.children) { + console.log(` ${c.deviceId} ${c.name} [${c.type}]`); + } + } + if (r.suggestedActions.length) { + console.log('suggested:'); + for (const s of r.suggestedActions) { + const param = s.parameter ? ` ${s.parameter}` : ''; + console.log(` ${s.description}: ${s.command}${param}`); + } + } +} diff --git a/src/commands/history.ts b/src/commands/history.ts new file mode 100644 index 0000000..fcd9bc0 --- /dev/null +++ b/src/commands/history.ts @@ -0,0 +1,101 @@ +import { Command } from 'commander'; +import path from 'node:path'; +import os from 'node:os'; +import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { readAudit, type AuditEntry } from '../utils/audit.js'; +import { executeCommand } from '../lib/devices.js'; + +const DEFAULT_AUDIT = path.join(os.homedir(), '.switchbot', 'audit.log'); + +export function registerHistoryCommand(program: Command): void { + const history = program + .command('history') + .description('View and replay commands recorded via --audit-log') + .addHelpText('after', ` +Every 'devices command' run with --audit-log is appended as JSONL to the +audit file (default ~/.switchbot/audit.log). 'history show' prints the file, +'history replay ' re-runs the Nth entry (1-indexed, most-recent last). + +Examples: + $ switchbot --audit-log devices command turnOff + $ switchbot history show --limit 10 + $ switchbot history replay 3 +`); + + history + .command('show') + .description('Print recent audit entries') + .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`) + .option('--limit ', 'Show only the last N entries') + .action((options: { file?: string; limit?: string }) => { + const file = options.file ?? DEFAULT_AUDIT; + const entries = readAudit(file); + const limited = + options.limit !== undefined + ? entries.slice(-Math.max(1, Number(options.limit) || 1)) + : entries; + + if (isJsonMode()) { + printJson({ file, total: entries.length, entries: limited }); + return; + } + if (entries.length === 0) { + console.log(`(no entries in ${file})`); + return; + } + const startIdx = entries.length - limited.length; + limited.forEach((e, i) => { + const idx = startIdx + i + 1; + const mark = e.result === 'error' ? '✗' : e.dryRun ? '◦' : '✓'; + const param = e.parameter !== undefined && e.parameter !== 'default' + ? ` ${JSON.stringify(e.parameter)}` + : ''; + const err = e.error ? ` [err: ${e.error}]` : ''; + console.log(`${String(idx).padStart(4)} ${mark} ${e.t} ${e.deviceId} ${e.command}${param}${err}`); + }); + }); + + history + .command('replay') + .description('Re-run a recorded command by its 1-indexed position') + .argument('', 'Entry index (1 = oldest; as shown by "history show")') + .option('--file ', `Path to the audit log (default ${DEFAULT_AUDIT})`) + .addHelpText('after', ` +Dry-run-honouring: pass --dry-run on the parent command to preview without +sending the actual call. Errors from the recorded entry are NOT replayed — +replay always attempts the command fresh. + +Examples: + $ switchbot history replay 3 + $ switchbot --dry-run history replay 3 +`) + .action(async (indexArg: string, options: { file?: string }) => { + const file = options.file ?? DEFAULT_AUDIT; + const entries = readAudit(file); + const idx = Number(indexArg); + if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) { + console.error(`Invalid index ${indexArg}. Log has ${entries.length} entries.`); + process.exit(2); + } + const entry: AuditEntry = entries[idx - 1]; + if (entry.kind !== 'command') { + console.error(`Entry ${idx} is not a command (kind=${entry.kind}).`); + process.exit(2); + } + try { + const result = await executeCommand( + entry.deviceId, + entry.command, + entry.parameter, + entry.commandType, + ); + if (isJsonMode()) { + printJson({ replayed: entry, result }); + } else { + console.log(`✓ replayed ${entry.command} on ${entry.deviceId}`); + } + } catch (err) { + handleError(err); + } + }); +} diff --git a/src/commands/schema.ts b/src/commands/schema.ts new file mode 100644 index 0000000..4986688 --- /dev/null +++ b/src/commands/schema.ts @@ -0,0 +1,84 @@ +import { Command } from 'commander'; +import { printJson, isJsonMode } from '../utils/output.js'; +import { getEffectiveCatalog, type CommandSpec, type DeviceCatalogEntry } from '../devices/catalog.js'; + +interface SchemaEntry { + type: string; + category: 'physical' | 'ir'; + aliases: string[]; + role: string | null; + readOnly: boolean; + commands: Array<{ + command: string; + parameter: string; + description: string; + commandType: 'command' | 'customize'; + idempotent: boolean; + destructive: boolean; + exampleParams?: string[]; + }>; + statusFields: string[]; +} + +function toSchemaEntry(e: DeviceCatalogEntry): SchemaEntry { + return { + type: e.type, + category: e.category, + aliases: e.aliases ?? [], + role: e.role ?? null, + readOnly: e.readOnly ?? false, + commands: e.commands.map(toSchemaCommand), + statusFields: e.statusFields ?? [], + }; +} + +function toSchemaCommand(c: CommandSpec) { + 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), + ...(c.exampleParams ? { exampleParams: c.exampleParams } : {}), + }; +} + +export function registerSchemaCommand(program: Command): void { + const schema = program + .command('schema') + .description('Dump the device catalog as machine-readable JSON Schema (for agent prompt / docs)'); + + schema + .command('export') + .description('Print the full catalog as JSON (one object per type)') + .option('--type ', 'Restrict to a single type (e.g. "Strip Light")') + .addHelpText('after', ` +Output is always JSON (this command ignores --format). Use 'schema export' to +pre-bake a prompt for an LLM, or to regenerate docs when the catalog bumps. + +Examples: + $ switchbot schema export > catalog.json + $ switchbot schema export --type Bot | jq '.types[0].commands' +`) + .action((options: { type?: string }) => { + const catalog = getEffectiveCatalog(); + const filtered = options.type + ? catalog.filter((e) => + e.type.toLowerCase() === options.type!.toLowerCase() || + (e.aliases ?? []).some((a) => a.toLowerCase() === options.type!.toLowerCase()), + ) + : catalog; + const payload = { + version: '1.0', + generatedAt: new Date().toISOString(), + types: filtered.map(toSchemaEntry), + }; + // Always JSON — schema export without JSON would be a category error. + if (isJsonMode()) { + printJson(payload); + } else { + console.log(JSON.stringify(payload, null, 2)); + } + }); +} diff --git a/src/index.ts b/src/index.ts index 843ae41..aaa75c3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,9 @@ import { registerQuotaCommand } from './commands/quota.js'; import { registerCatalogCommand } from './commands/catalog.js'; import { registerCacheCommand } from './commands/cache.js'; import { registerEventsCommand } from './commands/events.js'; +import { registerDoctorCommand } from './commands/doctor.js'; +import { registerSchemaCommand } from './commands/schema.js'; +import { registerHistoryCommand } from './commands/history.js'; const program = new Command(); @@ -29,6 +32,7 @@ program .option('--no-cache', 'Disable cache reads (equivalent to --cache off)') .option('--config ', 'Override credential file location (default: ~/.switchbot/config.json)') .option('--profile ', 'Use a named profile: ~/.switchbot/profiles/.json') + .option('--audit-log [path]', 'Append every mutating command to JSONL audit log (default ~/.switchbot/audit.log)') .showHelpAfterError('(run with --help to see usage)') .showSuggestionAfterError(); @@ -42,6 +46,9 @@ registerQuotaCommand(program); registerCatalogCommand(program); registerCacheCommand(program); registerEventsCommand(program); +registerDoctorCommand(program); +registerSchemaCommand(program); +registerHistoryCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 6fb9d88..9b5b15f 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -16,6 +16,8 @@ import { setCachedStatus, } from '../devices/cache.js'; import { getCacheMode } from '../utils/flags.js'; +import { writeAudit } from '../utils/audit.js'; +import { isDryRun } from '../utils/flags.js'; export interface Device { deviceId: string; @@ -159,11 +161,35 @@ export async function executeCommand( parameter: parameter ?? 'default', commandType, }; - const res = await c.post<{ body: unknown }>( - `/v1.1/devices/${deviceId}/commands`, - body - ); - return res.data.body; + const baseAudit = { + t: new Date().toISOString(), + kind: 'command' as const, + deviceId, + command: cmd, + parameter, + commandType, + dryRun: isDryRun(), + }; + try { + const res = await c.post<{ body: unknown }>( + `/v1.1/devices/${deviceId}/commands`, + body + ); + writeAudit({ ...baseAudit, result: 'ok' }); + return res.data.body; + } catch (err) { + // Dry-run intercepts throw DryRunSignal — still log the intent. + if (err instanceof Error && err.name === 'DryRunSignal') { + writeAudit({ ...baseAudit, result: 'ok' }); + } else { + writeAudit({ + ...baseAudit, + result: 'error', + error: err instanceof Error ? err.message : String(err), + }); + } + throw err; + } } /** diff --git a/src/utils/audit.ts b/src/utils/audit.ts new file mode 100644 index 0000000..be61c5b --- /dev/null +++ b/src/utils/audit.ts @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { getAuditLog } from './flags.js'; + +export interface AuditEntry { + t: string; + kind: 'command'; + deviceId: string; + command: string; + parameter: unknown; + commandType: 'command' | 'customize'; + dryRun: boolean; + result?: 'ok' | 'error'; + error?: string; +} + +function resolveAuditPath(): string | null { + const flag = getAuditLog(); + if (flag === null) return null; + return path.resolve(flag); +} + +export function writeAudit(entry: AuditEntry): void { + const file = resolveAuditPath(); + if (!file) return; + const dir = path.dirname(file); + try { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.appendFileSync(file, JSON.stringify(entry) + '\n'); + } catch { + // Best-effort — never let audit failures break the actual command. + } +} + +export function readAudit(file: string): AuditEntry[] { + if (!fs.existsSync(file)) return []; + const raw = fs.readFileSync(file, 'utf-8'); + const out: AuditEntry[] = []; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + out.push(JSON.parse(trimmed) as AuditEntry); + } catch { + // skip malformed lines + } + } + return out; +} diff --git a/src/utils/flags.ts b/src/utils/flags.ts index 835b70e..ba8aa9f 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -40,6 +40,22 @@ export function getProfile(): string | undefined { return getFlagValue('--profile'); } +/** + * Audit log path. `--audit-log ` enables JSONL append on every mutating + * command; default path is ~/.switchbot/audit.log when `--audit-log` is given + * without a value. Returns null when the flag is absent. + */ +export function getAuditLog(): string | null { + const idx = process.argv.indexOf('--audit-log'); + if (idx === -1) return null; + const next = process.argv[idx + 1]; + if (!next || next.startsWith('-')) { + // bare --audit-log → default location + return `${process.env.HOME ?? process.env.USERPROFILE ?? '.'}/.switchbot/audit.log`; + } + return next; +} + /** * Max 429 retries before surfacing the error. Default 3. `--no-retry` * disables retries entirely; `--retry-on-429 ` overrides the count. diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 57d93aa..91c0eb0 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -55,6 +55,8 @@ const flagsMock = vi.hoisted(() => ({ isVerbose: vi.fn(() => false), getTimeout: vi.fn(() => 30000), getConfigPath: vi.fn(() => undefined), + getProfile: vi.fn(() => undefined), + getAuditLog: vi.fn(() => null), getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), })); vi.mock('../../src/utils/flags.js', () => flagsMock); diff --git a/tests/commands/doctor.test.ts b/tests/commands/doctor.test.ts new file mode 100644 index 0000000..f1bc0fe --- /dev/null +++ b/tests/commands/doctor.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { registerDoctorCommand } from '../../src/commands/doctor.js'; +import { runCli } from '../helpers/cli.js'; + +describe('doctor command', () => { + let tmp: string; + let homedirSpy: ReturnType; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sbdoc-')); + homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(tmp); + delete process.env.SWITCHBOT_TOKEN; + delete process.env.SWITCHBOT_SECRET; + }); + afterEach(() => { + homedirSpy.mockRestore(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('exits 1 and reports credentials:fail when nothing is configured', async () => { + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + expect(res.exitCode).toBe(1); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(payload.overall).toBe('fail'); + const creds = payload.checks.find((c: { name: string }) => c.name === 'credentials'); + expect(creds.status).toBe('fail'); + expect(creds.detail).toMatch(/config set-token/); + }); + + it('reports credentials:ok when env vars are set', async () => { + process.env.SWITCHBOT_TOKEN = 't'; + process.env.SWITCHBOT_SECRET = 's'; + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + expect(res.exitCode).not.toBe(1); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const creds = payload.checks.find((c: { name: string }) => c.name === 'credentials'); + expect(creds.status).toBe('ok'); + expect(creds.detail).toMatch(/env/); + }); + + it('reports credentials:ok when the config file is valid', async () => { + fs.mkdirSync(path.join(tmp, '.switchbot'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.switchbot', 'config.json'), + JSON.stringify({ token: 't1', secret: 's1' }), + ); + const res = await runCli(registerDoctorCommand, ['--json', 'doctor']); + const payload = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + const creds = payload.checks.find((c: { name: string }) => c.name === 'credentials'); + expect(creds.status).toBe('ok'); + expect(creds.detail).toMatch(/config\.json/); + }); + + it('enumerates profiles when ~/.switchbot/profiles exists', async () => { + const pdir = path.join(tmp, '.switchbot', 'profiles'); + fs.mkdirSync(pdir, { recursive: true }); + fs.writeFileSync(path.join(pdir, 'work.json'), '{}'); + fs.writeFileSync(path.join(pdir, 'home.json'), '{}'); + 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 profiles = payload.checks.find((c: { name: string }) => c.name === 'profiles'); + expect(profiles.detail).toMatch(/found 2/); + expect(profiles.detail).toMatch(/home/); + expect(profiles.detail).toMatch(/work/); + }); + + it('catalog check reports the bundled type count', 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 cat = payload.checks.find((c: { name: string }) => c.name === 'catalog'); + expect(cat.detail).toMatch(/\d+ types loaded/); + }); +}); diff --git a/tests/commands/history.test.ts b/tests/commands/history.test.ts new file mode 100644 index 0000000..a4bf374 --- /dev/null +++ b/tests/commands/history.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { registerHistoryCommand } from '../../src/commands/history.js'; +import { runCli } from '../helpers/cli.js'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + }; +}); + +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'; + } + }, +})); + +describe('history command', () => { + let tmp: string; + let auditFile: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sbhist-')); + auditFile = path.join(tmp, 'audit.log'); + apiMock.__instance.post.mockReset(); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + function seed(entries: unknown[]): void { + const content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n'; + fs.writeFileSync(auditFile, content); + } + + describe('show', () => { + it('prints (no entries) when the log does not exist', async () => { + const res = await runCli(registerHistoryCommand, [ + 'history', 'show', '--file', auditFile, + ]); + expect(res.stdout.join('\n')).toMatch(/no entries/); + }); + + it('prints each entry with an index, mark and command', async () => { + seed([ + { t: '2026-04-18T10:00:00.000Z', kind: 'command', deviceId: 'BOT1', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + { t: '2026-04-18T10:00:05.000Z', kind: 'command', deviceId: 'BOT1', command: 'turnOff', parameter: undefined, commandType: 'command', dryRun: true, result: 'ok' }, + ]); + const res = await runCli(registerHistoryCommand, [ + 'history', 'show', '--file', auditFile, + ]); + const out = res.stdout.join('\n'); + expect(out).toMatch(/1\s+✓.*BOT1\s+turnOn/); + expect(out).toMatch(/2\s+◦.*BOT1\s+turnOff/); + }); + + it('--limit truncates to the last N entries but preserves the 1-based index', async () => { + seed([ + { t: 't1', kind: 'command', deviceId: 'A', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + { t: 't2', kind: 'command', deviceId: 'B', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + { t: 't3', kind: 'command', deviceId: 'C', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + ]); + const res = await runCli(registerHistoryCommand, [ + 'history', 'show', '--file', auditFile, '--limit', '2', + ]); + const out = res.stdout.join('\n'); + expect(out).not.toMatch(/ 1 /); + expect(out).toMatch(/2 .*\bB\b/); + expect(out).toMatch(/3 .*\bC\b/); + }); + + it('emits JSON with total + entries when --json is set', async () => { + seed([ + { t: 't1', kind: 'command', deviceId: 'A', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + ]); + const res = await runCli(registerHistoryCommand, [ + '--json', 'history', 'show', '--file', auditFile, + ]); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(out.total).toBe(1); + expect(out.entries[0].deviceId).toBe('A'); + }); + }); + + describe('replay', () => { + it('rejects an out-of-range index with exit 2', async () => { + seed([ + { t: 't1', kind: 'command', deviceId: 'A', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + ]); + const res = await runCli(registerHistoryCommand, [ + 'history', 'replay', '5', '--file', auditFile, + ]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/Invalid index/); + }); + + it('re-runs the command at the requested 1-based index', async () => { + seed([ + { t: 't1', kind: 'command', deviceId: 'BOT1', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + { t: 't2', kind: 'command', deviceId: 'BOT2', command: 'turnOff', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }, + ]); + apiMock.__instance.post.mockResolvedValueOnce({ + data: { statusCode: 100, body: {} }, + }); + + const res = await runCli(registerHistoryCommand, [ + 'history', 'replay', '2', '--file', auditFile, + ]); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + const [url, body] = apiMock.__instance.post.mock.calls[0]; + expect(url).toBe('/v1.1/devices/BOT2/commands'); + expect((body as { command: string }).command).toBe('turnOff'); + expect(res.stdout.join('\n')).toMatch(/replayed turnOff on BOT2/); + }); + }); +}); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts new file mode 100644 index 0000000..2d154f0 --- /dev/null +++ b/tests/commands/schema.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { registerSchemaCommand } from '../../src/commands/schema.js'; +import { runCli } from '../helpers/cli.js'; + +describe('schema export', () => { + it('dumps every catalog type as a JSON payload', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export']); + const out = res.stdout.join(''); + const parsed = JSON.parse(out); + expect(parsed.version).toBe('1.0'); + expect(parsed.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(Array.isArray(parsed.types)).toBe(true); + expect(parsed.types.length).toBeGreaterThan(10); + // Every entry should have normalized idempotent/destructive booleans. + for (const t of parsed.types) { + for (const c of t.commands) { + expect(typeof c.idempotent).toBe('boolean'); + expect(typeof c.destructive).toBe('boolean'); + } + } + }); + + it('filters by --type (matches name + aliases, case-insensitive)', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--type', 'bot']); + const parsed = JSON.parse(res.stdout.join('')); + expect(parsed.types).toHaveLength(1); + expect(parsed.types[0].type).toBe('Bot'); + }); + + it('returns an empty types[] when --type does not match', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--type', 'NoSuchType']); + const parsed = JSON.parse(res.stdout.join('')); + expect(parsed.types).toEqual([]); + }); + + it('tags a known destructive command', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export']); + const parsed = JSON.parse(res.stdout.join('')); + const lock = parsed.types.find( + (t: { type: string }) => t.type === 'Smart Lock' || t.type === 'Smart Lock Pro', + ); + if (!lock) return; // catalog may omit on some builds — soft assert + const unlock = lock.commands.find((c: { command: string }) => c.command === 'unlock'); + if (unlock) expect(unlock.destructive).toBe(true); + }); +}); diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index 89ff5b0..bce4be7 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -52,6 +52,8 @@ const flagsMock = vi.hoisted(() => ({ isVerbose: vi.fn(() => false), getTimeout: vi.fn(() => 30000), getConfigPath: vi.fn(() => undefined), + getProfile: vi.fn(() => undefined), + getAuditLog: vi.fn(() => null), getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), parseDurationToMs: (v: string): number | null => { const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase()); diff --git a/tests/utils/audit.test.ts b/tests/utils/audit.test.ts new file mode 100644 index 0000000..0625711 --- /dev/null +++ b/tests/utils/audit.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { writeAudit, readAudit } from '../../src/utils/audit.js'; + +describe('audit log', () => { + const originalArgv = process.argv; + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sbaudit-')); + process.argv = ['node', 'cli']; + }); + afterEach(() => { + process.argv = originalArgv; + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('writeAudit is a no-op when --audit-log flag is absent', () => { + const file = path.join(tmp, 'audit.log'); + writeAudit({ + t: new Date().toISOString(), + kind: 'command', + deviceId: 'BOT1', + command: 'turnOn', + parameter: undefined, + commandType: 'command', + dryRun: false, + result: 'ok', + }); + expect(fs.existsSync(file)).toBe(false); + }); + + it('writeAudit appends JSONL when --audit-log is set', () => { + const file = path.join(tmp, 'audit.log'); + process.argv = ['node', 'cli', '--audit-log', file]; + writeAudit({ + t: '2026-04-18T10:00:00.000Z', + kind: 'command', + deviceId: 'BOT1', + command: 'turnOn', + parameter: undefined, + commandType: 'command', + dryRun: false, + result: 'ok', + }); + writeAudit({ + t: '2026-04-18T10:00:05.000Z', + kind: 'command', + deviceId: 'BOT1', + command: 'turnOff', + parameter: undefined, + commandType: 'command', + dryRun: true, + result: 'ok', + }); + const raw = fs.readFileSync(file, 'utf-8'); + const lines = raw.split('\n').filter(Boolean); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0]).command).toBe('turnOn'); + expect(JSON.parse(lines[1]).command).toBe('turnOff'); + }); + + it('writeAudit creates the parent directory if missing', () => { + const file = path.join(tmp, 'nested', 'sub', 'audit.log'); + process.argv = ['node', 'cli', '--audit-log', file]; + writeAudit({ + t: '2026-04-18T10:00:00.000Z', + kind: 'command', + deviceId: 'X', + command: 'turnOn', + parameter: undefined, + commandType: 'command', + dryRun: false, + result: 'ok', + }); + expect(fs.existsSync(file)).toBe(true); + }); + + it('readAudit parses valid JSONL and skips malformed lines', () => { + const file = path.join(tmp, 'audit.log'); + const content = + JSON.stringify({ t: 't1', kind: 'command', deviceId: 'A', command: 'turnOn', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }) + + '\n{ malformed\n' + + JSON.stringify({ t: 't2', kind: 'command', deviceId: 'B', command: 'turnOff', parameter: undefined, commandType: 'command', dryRun: false, result: 'ok' }) + + '\n\n'; + fs.writeFileSync(file, content); + const entries = readAudit(file); + expect(entries).toHaveLength(2); + expect(entries[0].deviceId).toBe('A'); + expect(entries[1].deviceId).toBe('B'); + }); + + it('readAudit returns [] when the file does not exist', () => { + expect(readAudit(path.join(tmp, 'nope.log'))).toEqual([]); + }); +}); From 60c0d866d3a9969b176a71be55ee313e4ba0eb72 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 16:57:32 +0800 Subject: [PATCH 20/26] chore: release v1.12.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 89609fe..2845cb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.11.0", + "version": "1.12.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From 4fd0a5caf5fa6d1b75eff021684628d773dec0dc Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 17:03:28 +0800 Subject: [PATCH 21/26] feat(plan): add 'switchbot plan' (schema/validate/run) + agent-guide docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round out the agent story with a declarative plan runner. The plan JSON schema is fixed — agents emit plans, the CLI validates them, runs each step sequentially, and honours the destructive-command guard + --dry-run + --audit-log from earlier phases. No LLM inside the CLI. Three new subcommands: plan schema print JSON Schema for the plan format plan validate [file] validate a plan file or stdin (exit 2 on error) plan run [file] validate + execute; --yes allows destructive steps, --continue-on-error to keep running Plan steps: { type: command, deviceId, command, parameter? } | { type: scene, sceneId } | { type: wait, ms } (0..600000ms) README gets a top-of-file "Who is this for?" table pointing Human / Script / Agent users at their entry points. docs/agent-guide.md walks agents through MCP server, plan runner, direct JSON, catalog, safety rails, and token budgeting. 14 new tests (validatePlan unit + schema/validate/run integration). --- README.md | 14 ++ docs/agent-guide.md | 195 ++++++++++++++++++ src/commands/plan.ts | 393 ++++++++++++++++++++++++++++++++++++ src/index.ts | 4 +- tests/commands/plan.test.ts | 252 +++++++++++++++++++++++ 5 files changed, 857 insertions(+), 1 deletion(-) create mode 100644 docs/agent-guide.md create mode 100644 src/commands/plan.ts create mode 100644 tests/commands/plan.test.ts diff --git a/README.md b/README.md index 619d6f9..b41b564 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,20 @@ List devices, query live status, send control commands, run scenes, and manage w --- +## Who is this for? + +Three entry points, same binary — pick the one that matches how you use it: + +| Audience | Where to start | What you get | +|-----------|---------------------------------------------------------------|---------------------------------------------------------------------------------------------------| +| **Human** | this README ([Quick start](#quick-start)) | Colored tables, helpful hints on errors, shell completion, `switchbot doctor` self-check. | +| **Script**| [Output modes](#output-modes), [Scripting examples](#scripting-examples) | `--json`, `--format=tsv/yaml/id`, `--fields`, stable exit codes, `history replay`, audit log. | +| **Agent** | [`docs/agent-guide.md`](./docs/agent-guide.md) | `switchbot mcp serve` (stdio MCP server), `schema export`, `plan run`, destructive-command guard. | + +Under the hood every surface shares the same catalog, cache, and HMAC client — switching between them costs nothing. + +--- + ## Table of contents - [Features](#features) diff --git a/docs/agent-guide.md b/docs/agent-guide.md new file mode 100644 index 0000000..f1c6db6 --- /dev/null +++ b/docs/agent-guide.md @@ -0,0 +1,195 @@ +# Agent Guide + +This guide covers everything an LLM agent (Claude, GPT, Cursor, Zed, OpenClaw, a homegrown orchestrator…) needs to drive SwitchBot devices through the `switchbot` CLI **safely** and **reliably**, without the agent needing to guess at device-specific JSON payloads. + +If you're a human looking for a tour, start with the [top-level README](../README.md). This file assumes you're writing code that *calls* the CLI or embeds the MCP server. + +--- + +## Table of contents + +- [Three integration surfaces](#three-integration-surfaces) +- [Surface 1: MCP server (recommended)](#surface-1-mcp-server-recommended) +- [Surface 2: Structured plans (`switchbot plan`)](#surface-2-structured-plans-switchbot-plan) +- [Surface 3: Direct JSON invocation](#surface-3-direct-json-invocation) +- [Catalog: the shared contract](#catalog-the-shared-contract) +- [Safety rails](#safety-rails) +- [Observability](#observability) +- [Performance and token budget](#performance-and-token-budget) + +--- + +## Three integration surfaces + +All three share the same catalog, HMAC client, retry/backoff, destructive-command guard, cache, and audit-log. Choose based on how your agent is hosted: + +| Surface | Use when… | Entry point | +|-------------|----------------------------------------------------------------------------|-------------------------------------------------| +| MCP server | Your agent host speaks [MCP](https://modelcontextprotocol.io) (Claude Desktop, Cursor, Zed, Anthropic Agent SDK) | `switchbot mcp serve` (stdio) or `--port ` | +| Plan runner | Your agent is already producing structured JSON and you want the CLI to validate + execute it | `switchbot plan run ` / stdin | +| Direct CLI | Your agent wraps subprocesses and parses their output | Any subcommand with `--json` | + +--- + +## Surface 1: MCP server (recommended) + +```bash +switchbot mcp serve # stdio, for Claude Desktop / Cursor +switchbot mcp serve --port 8765 # http, for long-lived agent workers +``` + +### Claude Desktop config + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows): + +```json +{ + "mcpServers": { + "switchbot": { + "command": "switchbot", + "args": ["mcp", "serve"], + "env": { + "SWITCHBOT_TOKEN": "...", + "SWITCHBOT_SECRET": "..." + } + } + } +} +``` + +### Available tools + +| Tool | Purpose | Destructive-guard? | +|---------------------|-------------------------------------------------------------------|--------------------------| +| `list_devices` | Enumerate physical devices + IR remotes | — | +| `get_device_status` | Live status for one device | — | +| `send_command` | Dispatch a built-in or customize command | yes (`confirm: true` required) | +| `run_scene` | Execute a saved manual scene | — | +| `search_catalog` | Look up device type by name/alias | — | +| `describe_device` | Live status **plus** catalog-derived commands + suggested actions | — | + +The MCP server refuses destructive commands (Smart Lock `unlock`, Garage Door `open`, etc.) unless the tool call includes `confirm: true`. The allowed list is the `destructive: true` commands in the catalog — `switchbot schema export | jq '[.types[].commands[] | select(.destructive)]'` shows every one. + +--- + +## Surface 2: Structured plans (`switchbot plan`) + +Agents that prefer "emit JSON, let the CLI execute it" avoid the MCP dependency. The plan schema is fixed (versioned at `1.0`), so you can fine-tune prompts or tool definitions once and reuse them across models. + +### The schema + +```bash +switchbot plan schema > plan.schema.json +``` + +Give that file to your agent framework (OpenAI tool schema, Anthropic JSON mode, function-calling, etc.) and it will produce plans shaped like: + +```json +{ + "version": "1.0", + "description": "Evening wind-down", + "steps": [ + { "type": "command", "deviceId": "STRIP1", "command": "setColorTemperature", "parameter": 2700 }, + { "type": "wait", "ms": 500 }, + { "type": "command", "deviceId": "BOT1", "command": "turnOff" }, + { "type": "scene", "sceneId": "T_BEDTIME" } + ] +} +``` + +### Validate first, run later + +```bash +cat plan.json | switchbot plan validate - # exit 2 on schema error +cat plan.json | switchbot --dry-run plan run - # preview — mutations skipped +cat plan.json | switchbot plan run - --yes # allow destructive steps +cat plan.json | switchbot --json plan run - # machine-readable outcome +``` + +### Run semantics + +- Steps execute sequentially. A failed step stops the run (exit 1) unless you pass `--continue-on-error`. +- `wait` uses `setTimeout`; `ms` is capped at 600 000 so a malformed plan can't hang the agent. +- Destructive commands are **skipped** (not failed) without `--yes`, so an agent that omits the flag gets a clean "needs confirmation" summary. +- Every successful/failed step lands in `--audit-log` (see [Observability](#observability)). + +--- + +## Surface 3: Direct JSON invocation + +The CLI's `--json` flag covers every command. Pipe output through `jq` or parse it directly: + +```bash +switchbot --json devices list | jq '.deviceList[] | select(.deviceType=="Bot") | .deviceId' +switchbot --json devices describe +switchbot --json --dry-run devices command turnOff +switchbot --json scenes execute +``` + +Errors are also JSON when `--json` is set — stderr carries `{ "error": { "code", "message", "hint", "retryable" } }`. + +--- + +## Catalog: the shared contract + +Every device, command, and parameter the CLI knows about lives in the **catalog**. Dumping it gives you a prompt-ready description of the controllable surface area: + +```bash +switchbot schema export > catalog.json +switchbot schema export --type 'Smart Lock' | jq '.types[0].commands' +``` + +Each command entry carries: + +- `idempotent` — safe to retry +- `destructive` — requires explicit confirmation +- `parameter` / `exampleParams` — what the agent should fill in +- `commandType` (`command` vs `customize`) — built-in vs user-defined IR button + +Use `switchbot doctor` to confirm the CLI is healthy before orchestrating anything non-trivial — it validates credentials, catalog size, cache state, clock drift, and quota file access. + +--- + +## Safety rails + +1. **Destructive-command guard**: Smart Lock `unlock`, Garage Door `open`, and anything else tagged `destructive: true` in the catalog **refuses to run** without `--yes` (or `confirm: true` in MCP, or explicit dev intent). There is no bypass flag for autonomous agents beyond `--yes` — that's by design. +2. **Dry-run**: Global `--dry-run` short-circuits every mutating HTTP request. GETs still execute. Use it for any "what would this do?" flow before letting the agent commit. +3. **Quota**: The SwitchBot API has a per-account daily quota. `--retry-on-429 ` and `--backoff ` handle throttling; `~/.switchbot/quota.json` tracks daily counts. +4. **Audit log**: `--audit-log [path]` appends every mutating command (including dry-runs) to JSONL for post-hoc review. +5. **Non-zero exit codes are stable**: `0` success, `1` runtime error, `2` usage error (bad flag, invalid plan schema). + +--- + +## Observability + +```bash +switchbot --audit-log devices command turnOff # writes ~/.switchbot/audit.log +switchbot --audit-log=/tmp/agent.log plan run plan.json # custom path +switchbot history show --limit 20 # pretty-print recent entries +switchbot history replay 7 # re-run entry #7 +switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")' +``` + +The audit format is JSONL with this shape: + +```json +{ "t": "2026-04-18T10:00:00.000Z", "kind": "command", "deviceId": "BOT1", + "command": "turnOn", "parameter": null, "commandType": "command", + "dryRun": false, "result": "ok" } +``` + +Pair with `switchbot devices watch --interval=30s --on-change-only` for continuous state diffs, or `switchbot events tail --local` to receive webhook pushes locally. + +--- + +## Performance and token budget + +Agent contexts are expensive; the CLI is designed to be frugal. + +- `switchbot devices list --format=tsv --fields=id,name,type,online` — typical output ≤ 500 chars for a 20-device account (vs ~5 KB for the default JSON). +- `switchbot devices status --format=yaml` — compact key/value, no array noise. +- `switchbot schema export --type ` — bring only the relevant part of the catalog into context. +- `switchbot describe ` returns **both** the static catalog entry and the live status in one call — prefer it over separate `status` + `commands ` calls. +- Use `--cache=5m` when polling the same device repeatedly in a session; it caches live status locally so you don't burn the daily quota. + +If you're seeing token pressure, `switchbot doctor --json | jq .checks` will also show you how big the bundled catalog is, whether cache is active, and whether credentials round-trip cleanly. diff --git a/src/commands/plan.ts b/src/commands/plan.ts new file mode 100644 index 0000000..9e2c94e --- /dev/null +++ b/src/commands/plan.ts @@ -0,0 +1,393 @@ +import { Command } from 'commander'; +import fs from 'node:fs'; +import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { executeCommand, isDestructiveCommand } from '../lib/devices.js'; +import { executeScene } from '../lib/scenes.js'; +import { getCachedDevice } from '../devices/cache.js'; + +export interface PlanCommandStep { + type: 'command'; + deviceId: string; + command: string; + parameter?: unknown; + commandType?: 'command' | 'customize'; + note?: string; +} + +export interface PlanSceneStep { + type: 'scene'; + sceneId: string; + note?: string; +} + +export interface PlanWaitStep { + type: 'wait'; + ms: number; + note?: string; +} + +export type PlanStep = PlanCommandStep | PlanSceneStep | PlanWaitStep; + +export interface Plan { + version: '1.0'; + description?: string; + steps: PlanStep[]; +} + +const PLAN_JSON_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://switchbot.dev/plan-1.0.json', + title: 'SwitchBot Plan', + description: + 'Declarative batch of SwitchBot operations. Agent-authored; CLI validates and executes. No LLM inside the CLI — the schema is the contract.', + type: 'object', + required: ['version', 'steps'], + properties: { + version: { const: '1.0' }, + description: { type: 'string' }, + steps: { + type: 'array', + items: { + oneOf: [ + { + type: 'object', + required: ['type', 'deviceId', 'command'], + properties: { + type: { const: 'command' }, + deviceId: { type: 'string', minLength: 1 }, + command: { type: 'string', minLength: 1 }, + parameter: {}, + commandType: { enum: ['command', 'customize'] }, + note: { type: 'string' }, + }, + additionalProperties: false, + }, + { + type: 'object', + required: ['type', 'sceneId'], + properties: { + type: { const: 'scene' }, + sceneId: { type: 'string', minLength: 1 }, + note: { type: 'string' }, + }, + additionalProperties: false, + }, + { + type: 'object', + required: ['type', 'ms'], + properties: { + type: { const: 'wait' }, + ms: { type: 'integer', minimum: 0, maximum: 600000 }, + note: { type: 'string' }, + }, + additionalProperties: false, + }, + ], + }, + }, + }, + additionalProperties: false, +} as const; + +export interface PlanValidationIssue { + path: string; + message: string; +} + +export function validatePlan(raw: unknown): { + ok: true; + plan: Plan; +} | { ok: false; issues: PlanValidationIssue[] } { + const issues: PlanValidationIssue[] = []; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return { ok: false, issues: [{ path: '$', message: 'plan must be a JSON object' }] }; + } + const p = raw as Record; + if (p.version !== '1.0') { + issues.push({ path: 'version', message: 'must equal "1.0"' }); + } + if (!Array.isArray(p.steps)) { + issues.push({ path: 'steps', message: 'must be an array' }); + return { ok: false, issues }; + } + p.steps.forEach((step, i) => { + const at = `steps[${i}]`; + if (!step || typeof step !== 'object') { + issues.push({ path: at, message: 'must be an object' }); + return; + } + const s = step as Record; + switch (s.type) { + case 'command': + if (typeof s.deviceId !== 'string' || !s.deviceId) { + issues.push({ path: `${at}.deviceId`, message: 'must be a non-empty string' }); + } + if (typeof s.command !== 'string' || !s.command) { + issues.push({ path: `${at}.command`, message: 'must be a non-empty string' }); + } + if ( + s.commandType !== undefined && + s.commandType !== 'command' && + s.commandType !== 'customize' + ) { + issues.push({ + path: `${at}.commandType`, + message: 'must be "command" or "customize"', + }); + } + break; + case 'scene': + if (typeof s.sceneId !== 'string' || !s.sceneId) { + issues.push({ path: `${at}.sceneId`, message: 'must be a non-empty string' }); + } + break; + case 'wait': + if (typeof s.ms !== 'number' || !Number.isInteger(s.ms) || s.ms < 0 || s.ms > 600_000) { + issues.push({ + path: `${at}.ms`, + message: 'must be an integer in [0, 600000]', + }); + } + break; + default: + issues.push({ + path: `${at}.type`, + message: 'must be one of "command" | "scene" | "wait"', + }); + } + }); + if (issues.length > 0) return { ok: false, issues }; + return { ok: true, plan: raw as Plan }; +} + +async function readPlanSource(file: string | undefined): Promise { + const text = file === undefined || file === '-' + ? await readStdin() + : fs.readFileSync(file, 'utf8'); + if (!text.trim()) { + throw new Error( + file === undefined || file === '-' + ? 'no plan received on stdin' + : `plan file is empty: ${file}`, + ); + } + try { + return JSON.parse(text); + } catch (err) { + throw new Error(`plan is not valid JSON: ${(err as Error).message}`); + } +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => (buf += chunk)); + process.stdin.on('end', () => resolve(buf)); + process.stdin.on('error', reject); + }); +} + +interface PlanRunResult { + plan: Plan; + results: Array< + | { step: number; type: 'command'; deviceId: string; command: string; status: 'ok' | 'error' | 'skipped'; error?: string } + | { step: number; type: 'scene'; sceneId: string; status: 'ok' | 'error' | 'skipped'; error?: string } + | { step: number; type: 'wait'; ms: number; status: 'ok' | 'skipped' } + >; + summary: { total: number; ok: number; error: number; skipped: number }; +} + +export function registerPlanCommand(program: Command): void { + const plan = program + .command('plan') + .description('Agent-authored batch plans: schema, validate, run') + .addHelpText('after', ` +A "plan" is a JSON document describing a sequence of commands/scenes/waits. +The schema is fixed — agents emit plans, the CLI executes them. No LLM inside. + + { "version": "1.0", "description": "...", "steps": [ + { "type": "command", "deviceId": "...", "command": "turnOff" }, + { "type": "wait", "ms": 500 }, + { "type": "scene", "sceneId": "..." } + ]} + +Workflow: + $ switchbot plan schema > plan.schema.json # export the contract + $ switchbot plan validate my-plan.json # check shape without running + $ switchbot --dry-run plan run my-plan.json # preview (mutations skipped) + $ switchbot plan run my-plan.json --yes # execute destructive steps + $ cat plan.json | switchbot plan run - # or stream via stdin +`); + + plan + .command('schema') + .description('Print the JSON Schema for the plan format') + .action(() => { + printJson(PLAN_JSON_SCHEMA); + }); + + plan + .command('validate') + .description('Validate a plan file (or stdin) against the schema') + .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin') + .action(async (file: string | undefined) => { + let raw: unknown; + try { + raw = await readPlanSource(file); + } catch (err) { + handleError(err); + } + const result = validatePlan(raw); + if (!result.ok) { + if (isJsonMode()) { + printJson({ valid: false, issues: result.issues }); + } else { + console.error('✗ plan invalid:'); + for (const i of result.issues) { + console.error(` ${i.path}: ${i.message}`); + } + } + process.exit(2); + } + if (isJsonMode()) { + printJson({ valid: true, steps: result.plan.steps.length }); + } else { + console.log(`✓ plan valid (${result.plan.steps.length} step${result.plan.steps.length === 1 ? '' : 's'})`); + } + }); + + plan + .command('run') + .description('Validate + execute a plan. Respects --dry-run; destructive steps require --yes') + .argument('[file]', 'Path to plan.json, or "-" / omit to read stdin') + .option('--yes', 'Authorize destructive commands (e.g. Smart Lock unlock, Garage open)') + .option('--continue-on-error', 'Keep running after a failed step (default: stop at first error)') + .action( + async ( + file: string | undefined, + options: { yes?: boolean; continueOnError?: boolean }, + ) => { + let raw: unknown; + try { + raw = await readPlanSource(file); + } catch (err) { + handleError(err); + } + const v = validatePlan(raw); + if (!v.ok) { + if (isJsonMode()) { + printJson({ ran: false, issues: v.issues }); + } else { + console.error('✗ plan invalid, refusing to run:'); + for (const i of v.issues) console.error(` ${i.path}: ${i.message}`); + } + process.exit(2); + } + + const out: PlanRunResult = { + plan: v.plan, + results: [], + summary: { total: v.plan.steps.length, ok: 0, error: 0, skipped: 0 }, + }; + + try { + for (let i = 0; i < v.plan.steps.length; i++) { + const step = v.plan.steps[i]; + const idx = i + 1; + if (step.type === 'wait') { + await new Promise((r) => setTimeout(r, step.ms)); + out.results.push({ step: idx, type: 'wait', ms: step.ms, status: 'ok' }); + out.summary.ok++; + if (!isJsonMode()) console.log(` ${idx}. wait ${step.ms}ms`); + continue; + } + if (step.type === 'scene') { + try { + await executeScene(step.sceneId); + out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'ok' }); + out.summary.ok++; + if (!isJsonMode()) console.log(` ${idx}. ✓ scene ${step.sceneId}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + out.results.push({ step: idx, type: 'scene', sceneId: step.sceneId, status: 'error', error: msg }); + out.summary.error++; + if (!isJsonMode()) console.log(` ${idx}. ✗ scene ${step.sceneId}: ${msg}`); + if (!options.continueOnError) break; + } + continue; + } + // command + const deviceType = getCachedDevice(step.deviceId)?.type; + const commandType = step.commandType ?? 'command'; + const destructive = isDestructiveCommand(deviceType, step.command, commandType); + if (destructive && !options.yes) { + out.results.push({ + step: idx, + type: 'command', + deviceId: step.deviceId, + command: step.command, + status: 'skipped', + error: 'destructive — rerun with --yes', + }); + out.summary.skipped++; + if (!isJsonMode()) + console.log(` ${idx}. ⚠ skipped ${step.command} on ${step.deviceId} (destructive — pass --yes)`); + if (!options.continueOnError) break; + continue; + } + try { + await executeCommand(step.deviceId, step.command, step.parameter, commandType); + out.results.push({ + step: idx, + type: 'command', + deviceId: step.deviceId, + command: step.command, + status: 'ok', + }); + out.summary.ok++; + if (!isJsonMode()) + console.log(` ${idx}. ✓ ${step.command} on ${step.deviceId}`); + } catch (err) { + if (err instanceof Error && err.name === 'DryRunSignal') { + out.results.push({ + step: idx, + type: 'command', + deviceId: step.deviceId, + command: step.command, + status: 'ok', + }); + out.summary.ok++; + if (!isJsonMode()) + console.log(` ${idx}. ◦ dry-run ${step.command} on ${step.deviceId}`); + continue; + } + const msg = err instanceof Error ? err.message : String(err); + out.results.push({ + step: idx, + type: 'command', + deviceId: step.deviceId, + command: step.command, + status: 'error', + error: msg, + }); + out.summary.error++; + if (!isJsonMode()) + console.log(` ${idx}. ✗ ${step.command} on ${step.deviceId}: ${msg}`); + if (!options.continueOnError) break; + } + } + + if (isJsonMode()) { + printJson({ ran: true, ...out }); + } else { + const { ok, error, skipped, total } = out.summary; + console.log(`\nsummary: ok=${ok} error=${error} skipped=${skipped} total=${total}`); + } + } catch (err) { + handleError(err); + } + if (out.summary.error > 0) process.exit(1); + }, + ); +} diff --git a/src/index.ts b/src/index.ts index aaa75c3..6ee8a75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,13 +13,14 @@ import { registerEventsCommand } from './commands/events.js'; import { registerDoctorCommand } from './commands/doctor.js'; import { registerSchemaCommand } from './commands/schema.js'; import { registerHistoryCommand } from './commands/history.js'; +import { registerPlanCommand } from './commands/plan.js'; const program = new Command(); program .name('switchbot') .description('Command-line tool for SwitchBot API v1.1') - .version('1.0.0') + .version('2.0.0') .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') .option('-v, --verbose', 'Log HTTP request/response details to stderr') .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)') @@ -49,6 +50,7 @@ registerEventsCommand(program); registerDoctorCommand(program); registerSchemaCommand(program); registerHistoryCommand(program); +registerPlanCommand(program); program.addHelpText('after', ` Credentials: diff --git a/tests/commands/plan.test.ts b/tests/commands/plan.test.ts new file mode 100644 index 0000000..ce05dbf --- /dev/null +++ b/tests/commands/plan.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { + createClient: vi.fn(() => instance), + __instance: instance, + DryRunSignal: class DryRunSignal extends Error { + constructor(public readonly method: string, public readonly url: string) { + super('dry-run'); + this.name = 'DryRunSignal'; + } + }, + }; +}); + +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: apiMock.DryRunSignal, +})); + +const cacheMock = vi.hoisted(() => ({ + map: new Map(), + getCachedDevice: vi.fn((id: string) => cacheMock.map.get(id) ?? null), + updateCacheFromDeviceList: vi.fn(), +})); +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: cacheMock.getCachedDevice, + updateCacheFromDeviceList: cacheMock.updateCacheFromDeviceList, + 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 }, + })), +})); + +const flagsMock = vi.hoisted(() => ({ + dryRun: false, + isDryRun: vi.fn(() => flagsMock.dryRun), + isVerbose: vi.fn(() => false), + getTimeout: vi.fn(() => 30000), + getConfigPath: vi.fn(() => undefined), + getProfile: vi.fn(() => undefined), + getAuditLog: vi.fn(() => null), + getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), +})); +vi.mock('../../src/utils/flags.js', () => flagsMock); + +import { registerPlanCommand, validatePlan } from '../../src/commands/plan.js'; +import { runCli } from '../helpers/cli.js'; + +describe('plan command', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sbplan-')); + apiMock.__instance.post.mockReset(); + cacheMock.map.clear(); + flagsMock.dryRun = false; + }); + + function writePlan(obj: unknown): string { + const file = path.join(tmp, 'plan.json'); + fs.writeFileSync(file, JSON.stringify(obj)); + return file; + } + + describe('validatePlan (unit)', () => { + it('accepts a minimal valid plan', () => { + const res = validatePlan({ + version: '1.0', + steps: [{ type: 'command', deviceId: 'A', command: 'turnOn' }], + }); + expect(res.ok).toBe(true); + }); + + it('rejects wrong version', () => { + const res = validatePlan({ version: '2.0', steps: [] }); + if (res.ok) throw new Error('should have rejected'); + expect(res.issues.some((i) => i.path === 'version')).toBe(true); + }); + + it('rejects bad step types and captures the index', () => { + const res = validatePlan({ + version: '1.0', + steps: [ + { type: 'command', deviceId: 'A', command: 'turnOn' }, + { type: 'nope' }, + ], + }); + if (res.ok) throw new Error('should have rejected'); + expect(res.issues.some((i) => i.path === 'steps[1].type')).toBe(true); + }); + + it('rejects a wait step with out-of-range ms', () => { + const res = validatePlan({ + version: '1.0', + steps: [{ type: 'wait', ms: 999999999 }], + }); + if (res.ok) throw new Error('should have rejected'); + expect(res.issues.some((i) => i.path === 'steps[0].ms')).toBe(true); + }); + }); + + describe('plan schema', () => { + it('prints the JSON Schema', async () => { + const res = await runCli(registerPlanCommand, ['plan', 'schema']); + const parsed = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(parsed.$id).toMatch(/plan-1\.0/); + expect(parsed.required).toContain('steps'); + }); + }); + + describe('plan validate', () => { + it('exits 0 for a valid plan and reports step count', async () => { + const file = writePlan({ + version: '1.0', + steps: [ + { type: 'command', deviceId: 'A', command: 'turnOn' }, + { type: 'wait', ms: 200 }, + ], + }); + const res = await runCli(registerPlanCommand, ['plan', 'validate', file]); + expect(res.exitCode).not.toBe(2); + expect(res.stdout.join('\n')).toMatch(/2 steps/); + }); + + it('exits 2 with issue list for an invalid plan', async () => { + const file = writePlan({ version: '9', steps: 'nope' }); + const res = await runCli(registerPlanCommand, ['plan', 'validate', file]); + expect(res.exitCode).toBe(2); + expect(res.stderr.join('\n')).toMatch(/version/); + }); + + it('emits structured JSON output when --json is set', async () => { + const file = writePlan({ + version: '1.0', + steps: [{ type: 'command', deviceId: 'A', command: 'turnOn' }], + }); + const res = await runCli(registerPlanCommand, ['--json', 'plan', 'validate', file]); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(out.valid).toBe(true); + expect(out.steps).toBe(1); + }); + }); + + describe('plan run', () => { + it('executes commands + scenes + waits in order', async () => { + const file = writePlan({ + version: '1.0', + steps: [ + { type: 'command', deviceId: 'BOT1', command: 'turnOn' }, + { type: 'wait', ms: 0 }, + { type: 'scene', sceneId: 'S1' }, + ], + }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + + const res = await runCli(registerPlanCommand, ['plan', 'run', file]); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); + const urls = apiMock.__instance.post.mock.calls.map(([u]) => u); + expect(urls[0]).toBe('/v1.1/devices/BOT1/commands'); + expect(urls[1]).toBe('/v1.1/scenes/S1/execute'); + expect(res.stdout.join('\n')).toMatch(/ok=3/); + }); + + it('skips destructive commands without --yes and exits 0 (skipped, not failed)', async () => { + cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front', category: 'physical' }); + const file = writePlan({ + version: '1.0', + steps: [{ type: 'command', deviceId: 'LOCK1', command: 'unlock' }], + }); + const res = await runCli(registerPlanCommand, ['plan', 'run', file]); + expect(apiMock.__instance.post).not.toHaveBeenCalled(); + expect(res.stdout.join('\n')).toMatch(/skipped=1/); + }); + + it('runs destructive commands when --yes is passed', async () => { + cacheMock.map.set('LOCK1', { type: 'Smart Lock', name: 'Front', category: 'physical' }); + const file = writePlan({ + version: '1.0', + steps: [{ type: 'command', deviceId: 'LOCK1', command: 'unlock' }], + }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + const res = await runCli(registerPlanCommand, ['plan', 'run', file, '--yes']); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + expect(res.stdout.join('\n')).toMatch(/ok=1/); + }); + + it('stops at the first error by default and exits 1', async () => { + const file = writePlan({ + version: '1.0', + steps: [ + { type: 'command', deviceId: 'BOT1', command: 'turnOn' }, + { type: 'command', deviceId: 'BOT2', command: 'turnOn' }, + ], + }); + apiMock.__instance.post + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); + const res = await runCli(registerPlanCommand, ['plan', 'run', file]); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(1); + expect(res.exitCode).toBe(1); + }); + + it('--continue-on-error keeps running after a failed step', async () => { + const file = writePlan({ + version: '1.0', + steps: [ + { type: 'command', deviceId: 'BOT1', command: 'turnOn' }, + { type: 'command', deviceId: 'BOT2', command: 'turnOn' }, + ], + }); + apiMock.__instance.post + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce({ data: { statusCode: 100, body: {} } }); + const res = await runCli(registerPlanCommand, [ + 'plan', 'run', file, '--continue-on-error', + ]); + expect(apiMock.__instance.post).toHaveBeenCalledTimes(2); + expect(res.exitCode).toBe(1); + }); + + it('emits a structured summary when --json is set', async () => { + const file = writePlan({ + version: '1.0', + steps: [{ type: 'command', deviceId: 'BOT1', command: 'turnOn' }], + }); + apiMock.__instance.post.mockResolvedValue({ data: { statusCode: 100, body: {} } }); + const res = await runCli(registerPlanCommand, ['--json', 'plan', 'run', file]); + const out = JSON.parse(res.stdout.filter((l) => l.trim().startsWith('{')).join('')); + expect(out.ran).toBe(true); + expect(out.summary).toEqual({ total: 1, ok: 1, error: 0, skipped: 0 }); + }); + }); +}); From 5977c71509c4f848bf8208c1eccc4dadfe354fb1 Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 17:03:39 +0800 Subject: [PATCH 22/26] chore: release v2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cap the 14-step AI-agent optimization roadmap. Major bump because the plan runner + MCP server + agent guide formally establish "Agent" as a first-class user of this CLI, alongside Human and Script. No breaking changes to existing commands — every flag and exit code from v1.x stands. Step 14 landed: - switchbot plan schema | validate | run - README rewrite (top-level "Who is this for?" table) - docs/agent-guide.md (MCP / plan / direct / safety / observability) Cumulative v1 → v2 highlights: catalog coverage for 9 new device types (Step 1), --format/--fields (Step 3), MCP server (Step 5), batch (Step 6), 429 backoff + quota (Step 7), catalog refresh (Step 8), status cache (Step 9), destructive-command guard (Step 10), devices watch + events tail (Step 11), profiles + secret sources (Step 12), explain + doctor + schema export + history + audit-log (Step 13), plan runner + docs (Step 14). --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2845cb3..8957291 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "1.12.0", + "version": "2.0.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", From 312556baab7ba19c5c92bcad3f8d6b25b6fff27c Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 17:26:47 +0800 Subject: [PATCH 23/26] feat(format): add --format and --fields global flags for token-efficient output New src/utils/format.ts with parseFormat(), renderRows(), resolveFormat(), and resolveFields(). Supports table, json, jsonl, tsv, yaml, and id formats. --fields filters columns in list commands; --json remains backward-compatible (outputs raw API body). Updated devices list/types, scenes list, catalog show to use the new renderer. Unified watch --fields to read from global getFields(). 29 new tests (18 unit + 11 integration); 559/559 green. Co-Authored-By: Claude Opus 4.7 --- package.json | 2 +- src/commands/catalog.ts | 11 ++- src/commands/devices.ts | 23 +++-- src/commands/scenes.ts | 12 ++- src/commands/watch.ts | 8 +- src/index.ts | 4 +- src/utils/flags.ts | 12 +++ src/utils/format.ts | 121 +++++++++++++++++++++++ src/utils/output.ts | 4 +- tests/commands/batch.test.ts | 2 + tests/commands/devices.test.ts | 77 +++++++++++++++ tests/commands/plan.test.ts | 2 + tests/commands/scenes.test.ts | 30 ++++++ tests/commands/watch.test.ts | 3 + tests/helpers/cli.ts | 2 + tests/utils/format.test.ts | 172 +++++++++++++++++++++++++++++++++ 16 files changed, 463 insertions(+), 22 deletions(-) create mode 100644 src/utils/format.ts create mode 100644 tests/utils/format.test.ts diff --git a/package.json b/package.json index 8957291..3aafe99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.0.0", + "version": "2.1.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 23ea9ec..b660e86 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { printTable, printJson, isJsonMode } from '../utils/output.js'; +import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { DEVICE_CATALOG, findCatalogEntry, @@ -134,14 +135,20 @@ Examples: printJson(entries); return; } + const fmt = resolveFormat(); + const headers = ['type', 'category', 'commands', 'aliases']; const rows = entries.map((e) => [ e.type, e.category, String(e.commands.length), (e.aliases ?? []).join(', ') || '—', ]); - printTable(['type', 'category', 'commands', 'aliases'], rows); - console.log(`\nTotal: ${entries.length} device type(s) (source: ${source})`); + if (fmt !== 'table') { + renderRows(headers, rows, fmt, resolveFields()); + } else { + renderRows(headers, rows, 'table', resolveFields()); + console.log(`\nTotal: ${entries.length} device type(s) (source: ${source})`); + } }); catalog diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 699bc23..91c192d 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { printTable, printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js'; +import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; import { @@ -74,13 +75,15 @@ Examples: try { const body = await fetchDeviceList(); const { deviceList, infraredRemoteList } = body; + const fmt = resolveFormat(); - if (isJsonMode()) { + if (fmt === 'json' && process.argv.includes('--json')) { printJson(body); return; } const hubLocation = buildHubLocationMap(deviceList); + const headers = ['deviceId', 'deviceName', 'type', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud']; const rows: (string | boolean | null)[][] = []; for (const d of deviceList) { @@ -117,9 +120,11 @@ Examples: return; } - printTable(['deviceId', 'deviceName', 'type', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud'], rows); - console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`); - console.log(`Tip: 'switchbot devices describe ' shows a device's supported commands.`); + renderRows(headers, rows, fmt, resolveFields()); + if (fmt === 'table') { + console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`); + console.log(`Tip: 'switchbot devices describe ' shows a device's supported commands.`); + } } catch (error) { handleError(error); } @@ -313,18 +318,22 @@ Examples: `) .action(() => { const catalog = getEffectiveCatalog(); - if (isJsonMode()) { + const fmt = resolveFormat(); + if (fmt === 'json') { printJson(catalog); return; } + const headers = ['type', 'category', 'commands', 'aliases']; const rows = catalog.map((e) => [ e.type, e.category, String(e.commands.length), (e.aliases ?? []).join(', ') || '—', ]); - printTable(['type', 'category', 'commands', 'aliases'], rows); - console.log(`\nTotal: ${catalog.length} device type(s)`); + renderRows(headers, rows, fmt, resolveFields()); + if (fmt === 'table') { + console.log(`\nTotal: ${catalog.length} device type(s)`); + } }); // switchbot devices commands diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index 67baf5c..3d847cc 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; -import { printTable, printJson, isJsonMode, handleError } from '../utils/output.js'; +import { printJson, handleError } from '../utils/output.js'; +import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { fetchScenes, executeScene } from '../lib/scenes.js'; export function registerScenesCommand(program: Command): void { @@ -21,8 +22,9 @@ Examples: .action(async () => { try { const scenes = await fetchScenes(); + const fmt = resolveFormat(); - if (isJsonMode()) { + if (fmt === 'json') { printJson(scenes); return; } @@ -32,9 +34,11 @@ Examples: return; } - printTable( + renderRows( ['sceneId', 'sceneName'], - scenes.map((s) => [s.sceneId, s.sceneName]) + scenes.map((s) => [s.sceneId, s.sceneName]), + fmt, + resolveFields(), ); } catch (error) { handleError(error); diff --git a/src/commands/watch.ts b/src/commands/watch.ts index c819560..a643e37 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import { printJson, isJsonMode, handleError } from '../utils/output.js'; import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; -import { parseDurationToMs } from '../utils/flags.js'; +import { parseDurationToMs, getFields } from '../utils/flags.js'; const DEFAULT_INTERVAL_MS = 30_000; const MIN_INTERVAL_MS = 1_000; @@ -76,7 +76,6 @@ export function registerWatchCommand(devices: Command): void { '30s', ) .option('--max ', 'Stop after N ticks (default: run until Ctrl-C)') - .option('--fields ', 'Only track a subset of status fields (default: all)') .option('--include-unchanged', 'Emit a tick even when no field changed') .addHelpText( 'after', @@ -100,7 +99,6 @@ Examples: options: { interval: string; max?: string; - fields?: string; includeUnchanged?: boolean; }, ) => { @@ -123,9 +121,7 @@ Examples: maxTicks = Math.floor(n); } - const fields: string[] | null = options.fields - ? options.fields.split(',').map((s) => s.trim()).filter(Boolean) - : null; + const fields: string[] | null = getFields() ?? null; const ac = new AbortController(); const onSig = () => ac.abort(); diff --git a/src/index.ts b/src/index.ts index 6ee8a75..b816323 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,10 @@ const program = new Command(); program .name('switchbot') .description('Command-line tool for SwitchBot API v1.1') - .version('2.0.0') + .version('2.1.0') .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)') + .option('--format ', 'Output format: table (default), json, jsonl, tsv, yaml, id') + .option('--fields ', 'Comma-separated list of columns to include (e.g. --fields=id,name,type)') .option('-v, --verbose', 'Log HTTP request/response details to stderr') .option('--dry-run', 'Print mutating requests without sending them (GETs still execute)') .option('--timeout ', 'HTTP request timeout in milliseconds (default: 30000)') diff --git a/src/utils/flags.ts b/src/utils/flags.ts index ba8aa9f..a892cd4 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -119,6 +119,18 @@ function parseDurationToMs(v: string): number | null { export { parseDurationToMs }; +/** The --format flag value, or undefined when absent. */ +export function getFormat(): string | undefined { + return getFlagValue('--format'); +} + +/** Comma-separated --fields value, split into an array. */ +export function getFields(): string[] | undefined { + const v = getFlagValue('--fields'); + if (!v) return undefined; + return v.split(',').map((f) => f.trim()).filter(Boolean); +} + export function getCacheMode(): CacheMode { if (process.argv.includes('--no-cache')) { return { listTtlMs: 0, statusTtlMs: 0 }; diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..8f9a669 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,121 @@ +import { printTable, printJson, isJsonMode } from './output.js'; +import { getFormat, getFields } from './flags.js'; + +export type OutputFormat = 'table' | 'json' | 'jsonl' | 'tsv' | 'yaml' | 'id'; + +export function parseFormat(flag: string | undefined): OutputFormat { + if (!flag) return 'table'; + const lower = flag.toLowerCase(); + switch (lower) { + case 'table': return 'table'; + case 'json': return 'json'; + case 'jsonl': return 'jsonl'; + case 'tsv': return 'tsv'; + case 'yaml': return 'yaml'; + case 'id': return 'id'; + default: + console.error(`Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`); + process.exit(2); + } +} + +export function resolveFormat(): OutputFormat { + if (process.argv.includes('--json')) return 'json'; + return parseFormat(getFormat()); +} + +export function resolveFields(): string[] | undefined { + return getFields(); +} + +export function filterFields( + headers: string[], + rows: unknown[][], + fields: string[] | undefined, +): { headers: string[]; rows: unknown[][] } { + if (!fields || fields.length === 0) return { headers, rows }; + const indices = fields + .map((f) => headers.indexOf(f)) + .filter((i) => i !== -1); + if (indices.length === 0) return { headers, rows }; + return { + headers: indices.map((i) => headers[i]), + rows: rows.map((row) => indices.map((i) => row[i])), + }; +} + +function cellToString(cell: unknown): string { + if (cell === null || cell === undefined) return ''; + if (typeof cell === 'boolean') return cell ? 'true' : 'false'; + return String(cell); +} + +function rowToObject(headers: string[], row: unknown[]): Record { + const obj: Record = {}; + for (let i = 0; i < headers.length; i++) { + obj[headers[i]] = row[i] ?? null; + } + return obj; +} + +export function renderRows( + headers: string[], + rows: unknown[][], + format: OutputFormat, + fields?: string[], +): void { + const filtered = filterFields(headers, rows, fields); + const h = filtered.headers; + const r = filtered.rows; + + switch (format) { + case 'table': + printTable(h, r as (string | number | boolean | null | undefined)[][]); + break; + + case 'json': + printJson(r.map((row) => rowToObject(h, row))); + break; + + case 'jsonl': + for (const row of r) { + console.log(JSON.stringify(rowToObject(h, row))); + } + break; + + case 'tsv': + console.log(h.join('\t')); + for (const row of r) { + console.log(row.map(cellToString).join('\t')); + } + break; + + case 'yaml': + for (const row of r) { + const obj = rowToObject(h, row); + console.log('---'); + for (const [k, v] of Object.entries(obj)) { + if (v === null || v === undefined) { + console.log(`${k}: ~`); + } else if (typeof v === 'boolean') { + console.log(`${k}: ${v}`); + } else if (typeof v === 'number') { + console.log(`${k}: ${v}`); + } else { + console.log(`${k}: "${String(v).replace(/"/g, '\\"')}"`); + } + } + } + break; + + case 'id': { + const idIdx = h.indexOf('deviceId') !== -1 ? h.indexOf('deviceId') + : h.indexOf('sceneId') !== -1 ? h.indexOf('sceneId') + : 0; + for (const row of r) { + console.log(cellToString(row[idIdx])); + } + break; + } + } +} diff --git a/src/utils/output.ts b/src/utils/output.ts index 6069592..80516e1 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -2,8 +2,10 @@ import Table from 'cli-table3'; import chalk from 'chalk'; import { ApiError, DryRunSignal } from '../api/client.js'; +import { getFormat } from './flags.js'; + export function isJsonMode(): boolean { - return process.argv.includes('--json'); + return process.argv.includes('--json') || getFormat() === 'json'; } export function printJson(data: unknown): void { diff --git a/tests/commands/batch.test.ts b/tests/commands/batch.test.ts index 91c0eb0..33de630 100644 --- a/tests/commands/batch.test.ts +++ b/tests/commands/batch.test.ts @@ -58,6 +58,8 @@ const flagsMock = vi.hoisted(() => ({ getProfile: vi.fn(() => undefined), getAuditLog: vi.fn(() => null), getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), + getFormat: vi.fn(() => undefined), + getFields: vi.fn(() => undefined), })); vi.mock('../../src/utils/flags.js', () => flagsMock); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index d6af168..808c7a5 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -343,6 +343,69 @@ describe('devices command', () => { }); }); + describe('list --format', () => { + it('--format=tsv outputs tab-separated data', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'tsv']); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toContain('deviceId\t'); + expect(lines[0]).toContain('deviceName'); + expect(lines[1]).toContain('ABC123\t'); + expect(lines[1]).toContain('Living Lamp'); + }); + + it('--format=tsv --fields=deviceId,type shows only those columns', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'tsv', '--fields', 'deviceId,type']); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('deviceId\ttype'); + expect(lines[1]).toContain('ABC123'); + expect(lines[1]).not.toContain('Living Lamp'); + }); + + it('--format=id outputs one deviceId per line', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'id']); + const lines = res.stdout.join('\n').split('\n').filter(Boolean); + expect(lines).toContain('ABC123'); + expect(lines).toContain('BLE-001'); + expect(lines.every((l) => !l.includes('\t'))).toBe(true); + }); + + it('--format=jsonl outputs one JSON object per line', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'jsonl']); + const lines = res.stdout.join('\n').split('\n').filter(Boolean); + const first = JSON.parse(lines[0]); + expect(first.deviceId).toBe('ABC123'); + expect(first.deviceName).toBe('Living Lamp'); + }); + + it('--format=yaml outputs YAML documents', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'yaml']); + const out = res.stdout.join('\n'); + expect(out).toContain('---'); + expect(out).toContain('deviceId: "ABC123"'); + expect(out).toContain('deviceName: "Living Lamp"'); + }); + + it('--format=table still shows the footer summary', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'table']); + const out = res.stdout.join('\n'); + expect(out).toContain('3 physical device'); + expect(out).toContain('1 IR remote'); + }); + + it('--format=tsv suppresses the footer summary', async () => { + apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'tsv']); + const out = res.stdout.join('\n'); + expect(out).not.toContain('physical device'); + }); + }); + // ===================================================================== // status // ===================================================================== @@ -1029,6 +1092,20 @@ describe('devices command', () => { expect(out).toContain('"category"'); expect(out).toContain('"Bot"'); }); + + it('--format=tsv outputs tab-separated catalog rows', async () => { + const res = await runCli(registerDevicesCommand, ['devices', 'types', '--format', 'tsv']); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('type\tcategory\tcommands\taliases'); + expect(lines.find((l) => l.startsWith('Bot\t'))).toBeDefined(); + }); + + it('--format=id outputs one type per line', async () => { + const res = await runCli(registerDevicesCommand, ['devices', 'types', '--format', 'id']); + const lines = res.stdout.join('\n').split('\n').filter(Boolean); + expect(lines).toContain('Bot'); + expect(lines).toContain('Curtain'); + }); }); describe('commands (catalog lookup)', () => { diff --git a/tests/commands/plan.test.ts b/tests/commands/plan.test.ts index ce05dbf..f9f68b4 100644 --- a/tests/commands/plan.test.ts +++ b/tests/commands/plan.test.ts @@ -59,6 +59,8 @@ const flagsMock = vi.hoisted(() => ({ getProfile: vi.fn(() => undefined), getAuditLog: vi.fn(() => null), getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), + getFormat: vi.fn(() => undefined), + getFields: vi.fn(() => undefined), })); vi.mock('../../src/utils/flags.js', () => flagsMock); diff --git a/tests/commands/scenes.test.ts b/tests/commands/scenes.test.ts index c9acf0f..bfb9b38 100644 --- a/tests/commands/scenes.test.ts +++ b/tests/commands/scenes.test.ts @@ -76,6 +76,36 @@ describe('scenes command', () => { expect(res.exitCode).toBe(1); expect(res.stderr.join('\n')).toContain('server down'); }); + + it('--format=tsv outputs tab-separated scene data', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { + body: [ + { sceneId: 'S1', sceneName: 'Good Morning' }, + { sceneId: 'S2', sceneName: 'Movie Time' }, + ], + }, + }); + const res = await runCli(registerScenesCommand, ['scenes', 'list', '--format', 'tsv']); + const lines = res.stdout.join('\n').split('\n'); + expect(lines[0]).toBe('sceneId\tsceneName'); + expect(lines[1]).toBe('S1\tGood Morning'); + expect(lines[2]).toBe('S2\tMovie Time'); + }); + + it('--format=id outputs one sceneId per line', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { + body: [ + { sceneId: 'S1', sceneName: 'Good Morning' }, + { sceneId: 'S2', sceneName: 'Movie Time' }, + ], + }, + }); + const res = await runCli(registerScenesCommand, ['scenes', 'list', '--format', 'id']); + const lines = res.stdout.join('\n').split('\n').filter(Boolean); + expect(lines).toEqual(['S1', 'S2']); + }); }); describe('execute', () => { diff --git a/tests/commands/watch.test.ts b/tests/commands/watch.test.ts index bce4be7..f7b1215 100644 --- a/tests/commands/watch.test.ts +++ b/tests/commands/watch.test.ts @@ -55,6 +55,8 @@ const flagsMock = vi.hoisted(() => ({ getProfile: vi.fn(() => undefined), getAuditLog: vi.fn(() => null), getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), + getFormat: vi.fn(() => undefined), + getFields: vi.fn(() => undefined), parseDurationToMs: (v: string): number | null => { const m = /^(\d+)(ms|s|m|h)?$/.exec(v.trim().toLowerCase()); if (!m) return null; @@ -185,6 +187,7 @@ describe('devices watch', () => { cacheMock.map.set('BOT1', { type: 'Bot', name: 'K', category: 'physical' }); apiMock.__instance.get .mockResolvedValueOnce({ data: { statusCode: 100, body: { power: 'on', battery: 90, temp: 22 } } }); + flagsMock.getFields.mockReturnValueOnce(['power', 'battery']); const res = await runCli(registerDevicesCommand, [ '--json', 'devices', 'watch', 'BOT1', '--interval', '5s', '--max', '1', '--fields', 'power,battery', diff --git a/tests/helpers/cli.ts b/tests/helpers/cli.ts index 60b5ee5..13bf65f 100644 --- a/tests/helpers/cli.ts +++ b/tests/helpers/cli.ts @@ -21,6 +21,8 @@ export async function runCli( const program = new Command(); program.exitOverride(); program.option('--json', 'Output results in JSON format'); + program.option('--format ', 'Output format'); + program.option('--fields ', 'Column filter'); program.configureOutput({ writeOut: (str) => stdout.push(stripTrailingNewline(str)), writeErr: (str) => stderr.push(stripTrailingNewline(str)), diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts new file mode 100644 index 0000000..c19b200 --- /dev/null +++ b/tests/utils/format.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/utils/flags.js', () => ({ + isDryRun: vi.fn(() => false), + isVerbose: vi.fn(() => false), + getTimeout: vi.fn(() => 30000), + getConfigPath: vi.fn(() => undefined), + getProfile: vi.fn(() => undefined), + getAuditLog: vi.fn(() => null), + getCacheMode: vi.fn(() => ({ listTtlMs: 0, statusTtlMs: 0 })), + getFormat: vi.fn(() => undefined), + getFields: vi.fn(() => undefined), +})); + +import { parseFormat, filterFields, renderRows, resolveFormat, type OutputFormat } from '../../src/utils/format.js'; + +describe('parseFormat', () => { + it('returns table when undefined', () => { + expect(parseFormat(undefined)).toBe('table'); + }); + + it('parses all valid formats', () => { + expect(parseFormat('json')).toBe('json'); + expect(parseFormat('jsonl')).toBe('jsonl'); + expect(parseFormat('tsv')).toBe('tsv'); + expect(parseFormat('yaml')).toBe('yaml'); + expect(parseFormat('id')).toBe('id'); + expect(parseFormat('table')).toBe('table'); + }); + + it('is case-insensitive', () => { + expect(parseFormat('JSON')).toBe('json'); + expect(parseFormat('TSV')).toBe('tsv'); + expect(parseFormat('Yaml')).toBe('yaml'); + }); + + it('exits 2 for unknown format', () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('__exit__'); + }) as never); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + parseFormat('xml'); + } catch { /* expected */ } + expect(exitSpy).toHaveBeenCalledWith(2); + exitSpy.mockRestore(); + errSpy.mockRestore(); + }); +}); + +describe('filterFields', () => { + const headers = ['id', 'name', 'type', 'status']; + const rows = [ + ['A1', 'Light', 'Bot', 'on'], + ['A2', 'Lock', 'Smart Lock', 'locked'], + ]; + + it('returns all data when fields is undefined', () => { + const result = filterFields(headers, rows, undefined); + expect(result.headers).toEqual(headers); + expect(result.rows).toEqual(rows); + }); + + it('returns all data when fields is empty', () => { + const result = filterFields(headers, rows, []); + expect(result.headers).toEqual(headers); + expect(result.rows).toEqual(rows); + }); + + it('filters to requested columns', () => { + const result = filterFields(headers, rows, ['id', 'type']); + expect(result.headers).toEqual(['id', 'type']); + expect(result.rows).toEqual([['A1', 'Bot'], ['A2', 'Smart Lock']]); + }); + + it('ignores unknown field names', () => { + const result = filterFields(headers, rows, ['id', 'nonexistent']); + expect(result.headers).toEqual(['id']); + expect(result.rows).toEqual([['A1'], ['A2']]); + }); + + it('preserves requested field order', () => { + const result = filterFields(headers, rows, ['type', 'id']); + expect(result.headers).toEqual(['type', 'id']); + expect(result.rows).toEqual([['Bot', 'A1'], ['Smart Lock', 'A2']]); + }); +}); + +describe('renderRows', () => { + const headers = ['deviceId', 'name', 'type']; + const rows: unknown[][] = [ + ['DEV1', 'Light', 'Bot'], + ['DEV2', 'Door', 'Smart Lock'], + ]; + + let logOutput: string[]; + let logSpy: ReturnType; + + beforeEach(() => { + logOutput = []; + logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logOutput.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('tsv: outputs tab-separated header + data rows', () => { + renderRows(headers, rows, 'tsv'); + expect(logOutput[0]).toBe('deviceId\tname\ttype'); + expect(logOutput[1]).toBe('DEV1\tLight\tBot'); + expect(logOutput[2]).toBe('DEV2\tDoor\tSmart Lock'); + }); + + it('tsv: respects fields filter', () => { + renderRows(headers, rows, 'tsv', ['deviceId', 'type']); + expect(logOutput[0]).toBe('deviceId\ttype'); + expect(logOutput[1]).toBe('DEV1\tBot'); + }); + + it('jsonl: outputs one JSON object per line', () => { + renderRows(headers, rows, 'jsonl'); + const parsed0 = JSON.parse(logOutput[0]); + expect(parsed0).toEqual({ deviceId: 'DEV1', name: 'Light', type: 'Bot' }); + const parsed1 = JSON.parse(logOutput[1]); + expect(parsed1).toEqual({ deviceId: 'DEV2', name: 'Door', type: 'Smart Lock' }); + }); + + it('json: outputs a JSON array of objects', () => { + renderRows(headers, rows, 'json'); + const parsed = JSON.parse(logOutput.join('\n')); + expect(parsed).toEqual([ + { deviceId: 'DEV1', name: 'Light', type: 'Bot' }, + { deviceId: 'DEV2', name: 'Door', type: 'Smart Lock' }, + ]); + }); + + it('yaml: outputs YAML documents with --- separators', () => { + renderRows(headers, rows, 'yaml'); + const combined = logOutput.join('\n'); + expect(combined).toContain('---'); + expect(combined).toContain('deviceId: "DEV1"'); + expect(combined).toContain('name: "Light"'); + expect(combined).toContain('type: "Smart Lock"'); + }); + + it('id: outputs the first column (deviceId) by default', () => { + renderRows(headers, rows, 'id'); + expect(logOutput).toEqual(['DEV1', 'DEV2']); + }); + + it('id: picks sceneId column when present', () => { + renderRows(['sceneId', 'sceneName'], [['S1', 'Bedtime'], ['S2', 'Morning']], 'id'); + expect(logOutput).toEqual(['S1', 'S2']); + }); + + it('handles null/undefined/boolean cells in tsv', () => { + renderRows(['a', 'b', 'c'], [[null, undefined, true]], 'tsv'); + expect(logOutput[1]).toBe('\t\ttrue'); + }); + + it('handles null cells in yaml', () => { + renderRows(['a', 'b'], [[null, 'ok']], 'yaml'); + const combined = logOutput.join('\n'); + expect(combined).toContain('a: ~'); + expect(combined).toContain('b: "ok"'); + }); +}); + +import { afterEach } from 'vitest'; From d66367ffd7fddc357dbf9b88bb125b8b516e62af Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sat, 18 Apr 2026 17:26:54 +0800 Subject: [PATCH 24/26] chore: release v2.1.0 Co-Authored-By: Claude Opus 4.7 From a0d4463257d28734a4a1cf57100ec9df6b33dcba Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 02:06:54 +0800 Subject: [PATCH 25/26] feat: add capabilities command, catalog descriptions, schema role/category filters, cache improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'switchbot capabilities' command with full CLI manifest (commands, subcommands, args, flags, identity block, catalog stats) for agent bootstrap - Add description field to all 42 DeviceCatalogEntry types in catalog - Add --role and --category filters to 'schema export' - Expand MCP server instructions with product context, device categories, constraints, and recommended bootstrap sequence - Add in-memory hot-cache (resetListCache) and GC to status cache (evict entries > 24h) - Add independent --cache-list / --cache-status TTL overrides - Fix enableCloudService defaulting (false → true) - Update README with new commands, cache section, and project layout - 606 tests passing --- README.md | 178 ++++++++++++++-- docs/agent-guide.md | 40 +++- package-lock.json | 31 ++- src/api/client.ts | 4 +- src/commands/batch.ts | 37 ++-- src/commands/cache.ts | 41 ++-- src/commands/capabilities.ts | 103 +++++++++ src/commands/catalog.ts | 127 ++++++----- src/commands/config.ts | 30 ++- src/commands/devices.ts | 173 +++++++++------ src/commands/events.ts | 113 +++++----- src/commands/explain.ts | 9 - src/commands/history.ts | 14 +- src/commands/mcp.ts | 313 +++++++++++++++++++--------- src/commands/scenes.ts | 21 +- src/commands/schema.ts | 41 ++-- src/commands/watch.ts | 58 +++--- src/commands/webhook.ts | 65 +++--- src/devices/cache.ts | 55 ++++- src/devices/catalog.ts | 59 +++++- src/index.ts | 2 + src/lib/devices.ts | 112 +++++++++- src/utils/flags.ts | 14 ++ src/utils/format.ts | 46 ++-- src/utils/output.ts | 50 ++++- tests/api/client.test.ts | 2 +- tests/commands/cache.test.ts | 8 +- tests/commands/capabilities.test.ts | 130 ++++++++++++ tests/commands/devices.test.ts | 176 +++++++++++++--- tests/commands/explain.test.ts | 222 ++++++++++++++++++++ tests/commands/mcp-http.test.ts | 223 ++++++++++++++++++++ tests/commands/mcp.test.ts | 15 +- tests/commands/schema.test.ts | 35 ++++ tests/devices/cache.test.ts | 10 +- tests/devices/catalog.test.ts | 20 ++ tests/utils/format.test.ts | 20 +- tests/utils/output.test.ts | 132 ++++++++++++ 37 files changed, 2204 insertions(+), 525 deletions(-) create mode 100644 src/commands/capabilities.ts create mode 100644 tests/commands/capabilities.test.ts create mode 100644 tests/commands/explain.test.ts create mode 100644 tests/commands/mcp-http.test.ts diff --git a/README.md b/README.md index b41b564..c1c0d07 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,13 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - [`devices`](#devices--list-status-control) - [`scenes`](#scenes--run-manual-scenes) - [`webhook`](#webhook--receive-device-events-over-http) + - [`batch`](#batch--run-multiple-commands) + - [`watch`](#watch--poll-device-status) + - [`mcp`](#mcp--model-context-protocol-server) + - [`cache`](#cache--inspect-and-clear-local-cache) - [`completion`](#completion--shell-tab-completion) - [Output modes](#output-modes) +- [Cache](#cache-1) - [Exit codes & error codes](#exit-codes--error-codes) - [Environment variables](#environment-variables) - [Scripting examples](#scripting-examples) @@ -61,7 +66,7 @@ Under the hood every surface shares the same catalog, cache, and HMAC client — - 🎨 **Dual output modes** — colorized tables by default; `--json` passthrough for `jq` and scripting - 🔐 **Secure credentials** — HMAC-SHA256 signed requests; config file written with `0600`; env-var override for CI - 🔍 **Dry-run mode** — preview every mutating request before it hits the API -- 🧪 **Fully tested** — 282 Vitest tests, mocked axios, zero network in CI +- 🧪 **Fully tested** — 592 Vitest tests, mocked axios, zero network in CI - ⚡ **Shell completion** — Bash / Zsh / Fish / PowerShell ## Requirements @@ -133,15 +138,27 @@ switchbot config show ## Global options -| Option | Description | -| ------------------- | ------------------------------------------------------------------------ | -| `--json` | Print the raw JSON response instead of a formatted table | -| `-v`, `--verbose` | Log HTTP request/response details to stderr | -| `--dry-run` | Print mutating requests (POST/PUT/DELETE) without sending them | -| `--timeout ` | HTTP request timeout in milliseconds (default: `30000`) | -| `--config ` | Override credential file location (default: `~/.switchbot/config.json`) | -| `-V`, `--version` | Print the CLI version | -| `-h`, `--help` | Show help for any command or subcommand | +| Option | Description | +| --------------------------- | ------------------------------------------------------------------------ | +| `--json` | Print the raw JSON response instead of a formatted table | +| `--format ` | Output format: `tsv`, `yaml`, `jsonl`, `json`, `id` | +| `--fields ` | Comma-separated column names to include (e.g. `deviceId,type`) | +| `-v`, `--verbose` | Log HTTP request/response details to stderr | +| `--dry-run` | Print mutating requests (POST/PUT/DELETE) without sending them | +| `--timeout ` | HTTP request timeout in milliseconds (default: `30000`) | +| `--config ` | Override credential file location (default: `~/.switchbot/config.json`) | +| `--profile ` | Use a named credential profile (`~/.switchbot/profiles/.json`) | +| `--cache ` | Set list and status cache TTL, e.g. `5m`, `1h`, `off`, `auto` (default) | +| `--cache-list ` | Set list-cache TTL independently (overrides `--cache`) | +| `--cache-status ` | Set status-cache TTL independently (default off; overrides `--cache`) | +| `--no-cache` | Disable all cache reads for this invocation | +| `--retry-on-429 ` | Max 429 retry attempts (default: `3`) | +| `--no-retry` | Disable automatic 429 retries | +| `--backoff ` | Retry backoff: `exponential` (default) or `linear` | +| `--no-quota` | Disable local request-quota tracking | +| `--audit-log [path]` | Append mutating commands to a JSONL audit log (default path: `~/.switchbot/audit.log`) | +| `-V`, `--version` | Print the CLI version | +| `-h`, `--help` | Show help for any command or subcommand | Every subcommand supports `--help`, and most include a parameter-format reference and examples. @@ -175,10 +192,16 @@ switchbot config show # Print current source + masked s ```bash # List all physical devices and IR remote devices -# Columns: deviceId, deviceName, type, controlType, family, roomID, room, hub, cloud +# Default columns (4): deviceId, deviceName, type, category +# Pass --wide for the full 10-column operator view switchbot devices list +switchbot devices list --wide switchbot devices list --json | jq '.deviceList[].deviceId' +# IR remotes: type = remoteType (e.g. "TV"), category = "ir" +# Physical: category = "physical" +switchbot devices list --format=tsv --fields=deviceId,type,category + # Filter by family / room (family & room info requires the 'src: OpenClaw' # header, which this CLI sends on every request) switchbot devices list --json | jq '.deviceList[] | select(.familyName == "Home")' @@ -273,15 +296,115 @@ switchbot completion powershell >> $PROFILE Supported shells: `bash`, `zsh`, `fish`, `powershell` (`pwsh` is accepted as an alias). -## Output modes +### `batch` — run multiple commands + +```bash +# Run a sequence of commands from a JSON/YAML file +switchbot batch run commands.json +switchbot batch run commands.yaml --dry-run + +# Validate a plan file without executing it +switchbot batch validate commands.json +``` + +A batch file is a JSON array of `{ deviceId, command, parameter?, commandType? }` objects. + +### `watch` — poll device status + +```bash +# Poll a device's status every 30 s until Ctrl-C +switchbot watch +switchbot watch --interval 10s --json +``` + +Output is a stream of JSON status objects (with `--json`) or a refreshed table. + +### `mcp` — Model Context Protocol server + +```bash +# Start the stdio MCP server (connect via Claude, Cursor, etc.) +switchbot mcp serve +``` + +Exposes 7 MCP tools: `list_devices`, `describe_device`, `get_device_status`, `send_command`, `list_scenes`, `run_scene`, `search_catalog`. +See [`docs/agent-guide.md`](./docs/agent-guide.md) for the full tool reference and safety rules (destructive-command guard). + +### `cache` — inspect and clear local cache + +```bash +# Show cache status (paths, age, entry counts) +switchbot cache show + +# Clear everything +switchbot cache clear + +# Clear only the device-list cache or only the status cache +switchbot cache clear --key list +switchbot cache clear --key status +``` + + - **Default** — ANSI-colored tables for `list`/`status`, key-value tables for details. -- **`--json`** — raw JSON passthrough, ideal for `jq` and scripting. +- **`--json`** — raw API payload passthrough. Output is the exact JSON the SwitchBot API returned, ideal for `jq` and scripting. Errors are also JSON on stderr: `{ "error": { "code", "kind", "message", "hint?" } }`. +- **`--format=json`** — projected row view. Same JSON structure but built from the CLI's column model (`--fields` applies). Use this when you only want specific fields. +- **`--format=tsv|yaml|jsonl|id`** — tabular text formats; `--fields` filters columns. ```bash +# Raw API payload (--json) switchbot devices list --json | jq '.deviceList[] | {id: .deviceId, name: .deviceName}' + +# Projected rows with field filter (--format) +switchbot devices list --format tsv --fields deviceId,deviceName,type,cloud +switchbot devices list --format id # one deviceId per line +switchbot devices status --format yaml +``` + +## Cache + +The CLI maintains two local disk caches under `~/.switchbot/`: + +| File | Contents | Default TTL | +| ---- | -------- | ----------- | +| `devices.json` | Device metadata (id, name, type, category, hub, room…) | 1 hour | +| `status.json` | Per-device status bodies | off (0) | + +The device-list cache powers offline validation (command name checks, destructive-command guard) and the MCP server's `send_command` tool. It is refreshed automatically on every `devices list` call. + +### Cache control flags + +```bash +# Turn off all cache reads for one invocation +switchbot devices list --no-cache + +# Set both list and status TTL to 5 minutes +switchbot devices status --cache 5m + +# Set TTLs independently +switchbot devices status --cache-list 2h --cache-status 30s + +# Disable only the list cache (keep status cache at its current TTL) +switchbot devices list --cache-list 0 ``` +### Cache management commands + +```bash +# Show paths, age, and entry counts +switchbot cache show + +# Clear all cached data +switchbot cache clear + +# Scope the clear to one store +switchbot cache clear --key list +switchbot cache clear --key status +``` + +### Status-cache GC + +`status.json` entries are automatically evicted after 24 hours (or 10× the configured status TTL, whichever is longer), so the file cannot grow without bound even when the status cache is left enabled long-term. + ## Exit codes & error codes | Code | Meaning | @@ -332,7 +455,7 @@ npm install npm run dev -- # Run from TypeScript sources via tsx npm run build # Compile to dist/ -npm test # Run the Vitest suite (282 tests) +npm test # Run the Vitest suite (592 tests) npm run test:watch # Watch mode npm run test:coverage # Coverage report (v8, HTML + text) ``` @@ -346,17 +469,36 @@ src/ ├── config.ts # Credential load/save; env > file priority; --config override ├── api/client.ts # axios instance + request/response interceptors; │ # --verbose / --dry-run / --timeout wiring -├── devices/catalog.ts # Static catalog powering `devices types`/`devices commands` +├── devices/ +│ ├── catalog.ts # Static device catalog (commands, params, status fields) +│ └── cache.ts # Disk + in-memory cache for device list and status +├── lib/ +│ └── devices.ts # Shared logic: listDevices, describeDevice, isDestructiveCommand ├── commands/ │ ├── config.ts │ ├── devices.ts │ ├── scenes.ts │ ├── webhook.ts +│ ├── batch.ts # `switchbot batch run/validate` +│ ├── watch.ts # `switchbot watch ` +│ ├── mcp.ts # `switchbot mcp serve` (MCP stdio server) +│ ├── cache.ts # `switchbot cache show/clear` +│ ├── history.ts # `switchbot history [replay]` +│ ├── events.ts # `switchbot events` +│ ├── quota.ts # `switchbot quota` +│ ├── explain.ts # `switchbot explain ` +│ ├── plan.ts # `switchbot plan run ` +│ ├── doctor.ts # `switchbot doctor` +│ ├── schema.ts # `switchbot schema export` +│ ├── catalog.ts # `switchbot catalog search` │ └── completion.ts # `switchbot completion bash|zsh|fish|powershell` └── utils/ - ├── flags.ts # Global flag readers (isVerbose / isDryRun / getTimeout / getConfigPath) - └── output.ts # printTable / printKeyValue / printJson / handleError -tests/ # Vitest suite (282 tests, mocked axios, no network) + ├── flags.ts # Global flag readers (isVerbose / isDryRun / getCacheMode / …) + ├── output.ts # printTable / printKeyValue / printJson / handleError / buildErrorPayload + ├── format.ts # renderRows / filterFields / output-format dispatch + ├── audit.ts # JSONL audit log writer + └── quota.ts # Local daily-quota counter +tests/ # Vitest suite (592 tests, mocked axios, no network) ``` ### Release flow diff --git a/docs/agent-guide.md b/docs/agent-guide.md index f1c6db6..9648f7b 100644 --- a/docs/agent-guide.md +++ b/docs/agent-guide.md @@ -117,7 +117,22 @@ cat plan.json | switchbot --json plan run - # machine-readable outcome ## Surface 3: Direct JSON invocation -The CLI's `--json` flag covers every command. Pipe output through `jq` or parse it directly: +### `--json` vs `--format=json` — pick the right one + +| Flag | Output | When to use | +|------|--------|-------------| +| `--json` | **Raw API payload** — exact JSON the SwitchBot API returned | `jq` pipelines, scripts that need the full response body | +| `--format=json` | **Projected row view** — CLI column model, `--fields` applies | When you only need specific fields; consistent shape across all commands | + +`--json` and `--format=json` differ only in output shape — they share the same HTTP client and auth. + +Errors follow the same envelope on both paths (stderr): + +```json +{ "error": { "code": 152, "kind": "api", "message": "...", "hint": "...", "retryable": false } } +``` + +Error `kind` values: `api` (SwitchBot API error), `runtime` (network/auth failure), `usage` (bad flag or unknown field), `guard` (destructive command blocked without `confirm:true`). ```bash switchbot --json devices list | jq '.deviceList[] | select(.deviceType=="Bot") | .deviceId' @@ -126,7 +141,24 @@ switchbot --json --dry-run devices command turnOff switchbot --json scenes execute ``` -Errors are also JSON when `--json` is set — stderr carries `{ "error": { "code", "message", "hint", "retryable" } }`. +### `--fields` — strict column filter + +`--fields` projects output to a named subset of columns. Field names are the exact column headers a command outputs (listed in `--help`). Unknown names exit 2 immediately with the list of allowed names — there is no silent fallback. + +```bash +# Allowed fields for each command are in its --help text: +switchbot devices list --help # "Output columns: deviceId, deviceName, ..." +switchbot scenes list --help # "Output columns: sceneId, sceneName" + +# For `devices status`, fields are device-specific — discover them first: +switchbot devices status --format yaml # shows all field names for this device +switchbot devices status --format tsv --fields power,battery + +# --format=id only works on commands with a deviceId or sceneId column: +switchbot devices list --format id # ✓ — deviceId column present +switchbot scenes list --format id # ✓ — sceneId column present +switchbot devices status --format id # ✗ — exits 2 (no ID column in status output) +``` --- @@ -186,10 +218,10 @@ Pair with `switchbot devices watch --interval=30s --on-change-only` for continuo Agent contexts are expensive; the CLI is designed to be frugal. -- `switchbot devices list --format=tsv --fields=id,name,type,online` — typical output ≤ 500 chars for a 20-device account (vs ~5 KB for the default JSON). +- `switchbot devices list --format=tsv --fields=deviceId,deviceName,type,cloud` — typical output ≤ 500 chars for a 20-device account (vs ~5 KB for the default JSON). - `switchbot devices status --format=yaml` — compact key/value, no array noise. - `switchbot schema export --type ` — bring only the relevant part of the catalog into context. -- `switchbot describe ` returns **both** the static catalog entry and the live status in one call — prefer it over separate `status` + `commands ` calls. +- `switchbot devices describe --live` returns **both** the static catalog entry and live status in one call — prefer it over separate `status` + `commands ` calls. - Use `--cache=5m` when polling the same device repeatedly in a session; it caches live status locally so you don't burn the daily quota. If you're seeing token pressure, `switchbot doctor --json | jq .checks` will also show you how big the bundled catalog is, whether cache is active, and whether credentials round-trip cleanly. diff --git a/package-lock.json b/package-lock.json index 458a82e..3ba4c7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@switchbot/openapi-cli", - "version": "1.3.0", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@switchbot/openapi-cli", - "version": "1.3.0", + "version": "2.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -14,12 +14,14 @@ "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0", + "js-yaml": "^4.1.1", "uuid": "^11.0.5" }, "bin": { "switchbot": "dist/index.js" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.7", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^2.1.9", @@ -1095,6 +1097,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -1351,6 +1360,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2442,6 +2457,18 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", diff --git a/src/api/client.ts b/src/api/client.ts index 90fb9b1..3a3876a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -65,9 +65,9 @@ export function createClient(): AxiosInstance { } if (dryRun && method !== 'GET') { - console.log(chalk.yellow(`[dry-run] Would ${method} ${url}`)); + process.stderr.write(chalk.yellow(`[dry-run] Would ${method} ${url}\n`)); if (config.data !== undefined) { - console.log(chalk.yellow(`[dry-run] body: ${JSON.stringify(config.data)}`)); + process.stderr.write(chalk.yellow(`[dry-run] body: ${JSON.stringify(config.data)}\n`)); } throw new DryRunSignal(method, url); } diff --git a/src/commands/batch.ts b/src/commands/batch.ts index ab16e3b..82fc5f5 100644 --- a/src/commands/batch.ts +++ b/src/commands/batch.ts @@ -177,11 +177,19 @@ Examples: }); } catch (error) { if (error instanceof FilterSyntaxError) { - console.error(`Error: ${error.message}`); + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } })); + } else { + console.error(`Error: ${error.message}`); + } process.exit(2); } if (error instanceof Error && error.message.startsWith('No target devices')) { - console.error(`Error: ${error.message}`); + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: error.message } })); + } else { + console.error(`Error: ${error.message}`); + } process.exit(2); } handleError(error); @@ -215,22 +223,17 @@ Examples: } if (blockedForDestructive.length > 0 && !options.yes) { - const out: BatchResult = { - succeeded: [], - failed: blockedForDestructive.map((b) => ({ - deviceId: b.deviceId, - error: b.reason, - })), - summary: { - total: resolved.ids.length, - ok: 0, - failed: blockedForDestructive.length, - skipped: resolved.ids.length - blockedForDestructive.length, - durationMs: 0, - }, - }; if (isJsonMode()) { - printJson(out); + const deviceIds = blockedForDestructive.map((b) => b.deviceId); + console.error(JSON.stringify({ + error: { + 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:` diff --git a/src/commands/cache.ts b/src/commands/cache.ts index 99acfd1..a355d3c 100644 --- a/src/commands/cache.ts +++ b/src/commands/cache.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printJson, isJsonMode } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { clearCache, clearStatusCache, @@ -90,24 +90,27 @@ Examples: .description('Delete cache files') .option('--key ', 'Which cache to clear: "list" | "status" | "all" (default)', 'all') .action((options: { key: string }) => { - const key = options.key; - if (!['list', 'status', 'all'].includes(key)) { - console.error(`Unknown --key "${key}". Expected: list, status, all.`); - process.exit(2); + try { + const key = options.key; + if (!['list', 'status', 'all'].includes(key)) { + throw new UsageError(`Unknown --key "${key}". Expected: list, status, all.`); + } + const cleared: string[] = []; + if (key === 'list' || key === 'all') { + clearCache(); + cleared.push('list'); + } + if (key === 'status' || key === 'all') { + clearStatusCache(); + cleared.push('status'); + } + if (isJsonMode()) { + printJson({ cleared }); + return; + } + console.log(`Cleared: ${cleared.join(', ')}`); + } catch (error) { + handleError(error); } - const cleared: string[] = []; - if (key === 'list' || key === 'all') { - clearCache(); - cleared.push('list'); - } - if (key === 'status' || key === 'all') { - clearStatusCache(); - cleared.push('status'); - } - if (isJsonMode()) { - printJson({ cleared }); - return; - } - console.log(`Cleared: ${cleared.join(', ')}`); }); } diff --git a/src/commands/capabilities.ts b/src/commands/capabilities.ts new file mode 100644 index 0000000..dd86bc2 --- /dev/null +++ b/src/commands/capabilities.ts @@ -0,0 +1,103 @@ +import { Command } from 'commander'; +import { getEffectiveCatalog } from '../devices/catalog.js'; + +const IDENTITY = { + product: 'SwitchBot', + domain: 'IoT smart home device control', + vendor: 'Wonderlabs, Inc.', + apiVersion: 'v1.1', + apiDocs: 'https://github.com/OpenWonderLabs/SwitchBotAPI', + deviceCategories: { + physical: 'Wi-Fi/BLE devices controllable via Cloud API (Hub required for BLE-only)', + ir: 'IR remote devices learned by a SwitchBot Hub (TV, AC, etc.)', + }, + constraints: { + quotaPerDay: 10000, + bleRequiresHub: true, + authMethod: 'HMAC-SHA256 token+secret', + }, + agentGuide: 'docs/agent-guide.md', +}; + +const MCP_TOOLS = [ + 'list_devices', + 'get_device_status', + 'send_command', + 'describe_device', + 'list_scenes', + 'run_scene', + 'search_catalog', +]; + +export function registerCapabilitiesCommand(program: Command): void { + program + .command('capabilities') + .description('Print a machine-readable manifest of CLI capabilities (for agent bootstrap)') + .action(() => { + const catalog = getEffectiveCatalog(); + const commands = program.commands + .filter((c) => c.name() !== 'capabilities') + .map((c) => ({ + name: c.name(), + description: c.description(), + subcommands: c.commands.map((s) => ({ + name: s.name(), + description: s.description(), + args: s.registeredArguments.map((a) => ({ + name: a.name(), + required: a.required, + variadic: a.variadic, + })), + flags: s.options.map((o) => ({ + flags: o.flags, + description: o.description, + })), + })), + })); + const globalFlags = program.options.map((opt) => ({ + flags: opt.flags, + description: opt.description, + })); + const roles = [...new Set(catalog.map((e) => e.role ?? 'other'))].sort(); + console.log( + JSON.stringify( + { + version: program.version(), + generatedAt: new Date().toISOString(), + identity: IDENTITY, + surfaces: { + mcp: { + entry: 'mcp serve', + protocol: 'stdio (default) or --port for HTTP', + tools: MCP_TOOLS, + }, + plan: { + schemaCmd: 'plan schema', + validateCmd: 'plan validate -', + runCmd: 'plan run -', + }, + cli: { + catalogCmd: 'schema export', + discoveryCmd: 'capabilities', + healthCmd: 'doctor --json', + helpFlag: '--help', + }, + }, + commands, + globalFlags, + catalog: { + typeCount: catalog.length, + roles, + destructiveCommandCount: catalog.reduce( + (n, e) => n + e.commands.filter((c) => c.destructive).length, + 0, + ), + readOnlyTypeCount: catalog.filter((e) => e.readOnly).length, + }, + }, + null, + 2, + ), + ); + }); +} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index b660e86..7b32a7b 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printTable, printJson, isJsonMode } from '../utils/output.js'; +import { printTable, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { DEVICE_CATALOG, @@ -78,76 +78,75 @@ Examples: .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)') .option('--source ', 'Which catalog to show: built-in | overlay | effective (default)', 'effective') .action((typeParts: string[], options: { source: string }) => { - const source = options.source; - if (!['built-in', 'overlay', 'effective'].includes(source)) { - console.error(`Unknown --source "${source}". Expected: built-in, overlay, effective.`); - process.exit(2); - } - - let entries: DeviceCatalogEntry[]; - if (source === 'built-in') { - entries = DEVICE_CATALOG; - } else if (source === 'overlay') { - const overlay = loadCatalogOverlay(); - if (overlay.error) { - console.error(`Overlay file is invalid: ${overlay.error}`); - process.exit(1); + try { + const source = options.source; + if (!['built-in', 'overlay', 'effective'].includes(source)) { + throw new UsageError(`Unknown --source "${source}". Expected: built-in, overlay, effective.`); } - // Only entries that are full catalog entries (have category + commands) - // or that explicitly remove a built-in are rendered here. Partial - // overrides are hidden because they're not self-contained entries; - // use `catalog diff` to inspect them. - entries = overlay.entries.filter( - (e): e is DeviceCatalogEntry => - e.category !== undefined && e.commands !== undefined && !e.remove - ); - } else { - entries = getEffectiveCatalog(); - } - const typeQuery = typeParts.join(' ').trim(); - if (typeQuery) { - const match = findCatalogEntry(typeQuery); - if (!match) { - console.error(`No device type matches "${typeQuery}".`); - process.exit(2); - } - if (Array.isArray(match)) { - console.error(`"${typeQuery}" matches multiple types. Be more specific:`); - for (const m of match) console.error(` • ${m.type}`); - process.exit(2); + let entries: DeviceCatalogEntry[]; + if (source === 'built-in') { + entries = DEVICE_CATALOG; + } else if (source === 'overlay') { + const overlay = loadCatalogOverlay(); + if (overlay.error) { + throw new Error(`Overlay file is invalid: ${overlay.error}`); + } + // Only entries that are full catalog entries (have category + commands) + // or that explicitly remove a built-in are rendered here. Partial + // overrides are hidden because they're not self-contained entries; + // use `catalog diff` to inspect them. + entries = overlay.entries.filter( + (e): e is DeviceCatalogEntry => + e.category !== undefined && e.commands !== undefined && !e.remove + ); + } else { + entries = getEffectiveCatalog(); } - // Restrict the match to the requested source if needed. - const picked = entries.find((e) => e.type === match.type); - if (!picked) { - console.error(`"${match.type}" exists in the effective catalog but not in source "${source}".`); - process.exit(2); + + const typeQuery = typeParts.join(' ').trim(); + if (typeQuery) { + const match = findCatalogEntry(typeQuery); + if (!match) { + throw new UsageError(`No device type matches "${typeQuery}".`); + } + if (Array.isArray(match)) { + const types = match.map((m) => m.type).join(', '); + throw new UsageError(`"${typeQuery}" matches multiple types: ${types}. Be more specific.`); + } + // Restrict the match to the requested source if needed. + const picked = entries.find((e) => e.type === match.type); + if (!picked) { + throw new UsageError(`"${match.type}" exists in the effective catalog but not in source "${source}".`); + } + if (isJsonMode()) { + printJson(picked); + return; + } + renderEntry(picked); + return; } + if (isJsonMode()) { - printJson(picked); + printJson(entries); return; } - renderEntry(picked); - return; - } - - if (isJsonMode()) { - printJson(entries); - return; - } - const fmt = resolveFormat(); - const headers = ['type', 'category', 'commands', 'aliases']; - const rows = entries.map((e) => [ - e.type, - e.category, - String(e.commands.length), - (e.aliases ?? []).join(', ') || '—', - ]); - if (fmt !== 'table') { - renderRows(headers, rows, fmt, resolveFields()); - } else { - renderRows(headers, rows, 'table', resolveFields()); - console.log(`\nTotal: ${entries.length} device type(s) (source: ${source})`); + const fmt = resolveFormat(); + const headers = ['type', 'category', 'commands', 'aliases']; + const rows = entries.map((e) => [ + e.type, + e.category, + String(e.commands.length), + (e.aliases ?? []).join(', ') || '—', + ]); + if (fmt !== 'table') { + renderRows(headers, rows, fmt, resolveFields()); + } else { + renderRows(headers, rows, 'table', resolveFields()); + console.log(`\nTotal: ${entries.length} device type(s) (source: ${source})`); + } + } catch (error) { + handleError(error); } }); diff --git a/src/commands/config.ts b/src/commands/config.ts index 9d5da6f..5ef76c5 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -72,7 +72,12 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/ for the secret reference.'); + const msg = '--from-op requires --op-secret for the secret reference.'; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } process.exit(2); } try { token = readFromOp(options.fromOp); secret = readFromOp(options.opSecret); } catch (err) { - console.error(`1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`); - console.error('Ensure the "op" CLI is installed and authenticated (op signin).'); + const msg = `1Password CLI read failed: ${err instanceof Error ? err.message : String(err)}`; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { 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); } } if (!token || !secret) { - console.error('Missing token/secret. Provide positional arguments or use --from-env-file / --from-op.'); + const msg = 'Missing token/secret. Provide positional arguments or use --from-env-file / --from-op.'; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } process.exit(2); } diff --git a/src/commands/devices.ts b/src/commands/devices.ts index 91c192d..d373fba 100644 --- a/src/commands/devices.ts +++ b/src/commands/devices.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printTable, printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js'; +import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { findCatalogEntry, getEffectiveCatalog, DeviceCatalogEntry } from '../devices/catalog.js'; import { getCachedDevice } from '../devices/cache.js'; @@ -10,6 +10,7 @@ import { describeDevice, validateCommand, isDestructiveCommand, + getDestructiveReason, buildHubLocationMap, DeviceNotFoundError, type Device, @@ -49,9 +50,12 @@ Run any subcommand with --help for its own flags and examples. .command('list') .description('List all physical devices and IR remote devices on the account') .addHelpText('after', ` -Output columns: deviceId, deviceName, type, controlType, family, roomID, room, hub, cloud +Default columns: deviceId, deviceName, type, category +Pass --wide for the full operator view: + controlType, family, roomID, room, hub, cloud +--fields accepts any subset of all column names (exit 2 on unknown names). - type - physical deviceType (e.g. "Bot", "Curtain") or "[IR] " + type - physical deviceType (e.g. "Bot", "Curtain") or IR remoteType (e.g. "TV") + category - "physical" or "ir" controlType - functional classification from the API (e.g. "Bot", "Switch", "TV") — may differ from 'type' and groups devices by behavior family - home/family name (IR remotes inherit this from their bound Hub) @@ -68,10 +72,13 @@ the table; --json returns the raw API body unchanged.) Examples: $ switchbot devices list + $ switchbot devices list --wide + $ switchbot devices list --format tsv --fields deviceId,deviceName,type,category $ switchbot devices list --json | jq '.deviceList[] | select(.familyName == "家里")' $ switchbot devices list --json | jq '[.deviceList[], .infraredRemoteList[]] | group_by(.familyName)' `) - .action(async () => { + .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)') + .action(async (options: { wide?: boolean }) => { try { const body = await fetchDeviceList(); const { deviceList, infraredRemoteList } = body; @@ -83,7 +90,11 @@ Examples: } const hubLocation = buildHubLocationMap(deviceList); - const headers = ['deviceId', 'deviceName', 'type', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud']; + + const narrowHeaders = ['deviceId', 'deviceName', 'type', 'category']; + const wideHeaders = ['deviceId', 'deviceName', 'type', 'category', 'controlType', 'family', 'roomID', 'room', 'hub', 'cloud']; + const userFields = resolveFields(); + const headers = userFields ? wideHeaders : (options.wide ? wideHeaders : narrowHeaders); const rows: (string | boolean | null)[][] = []; for (const d of deviceList) { @@ -91,6 +102,7 @@ Examples: d.deviceId, d.deviceName, d.deviceType || '—', + 'physical', d.controlType || '—', d.familyName || '—', d.roomID || '—', @@ -105,7 +117,8 @@ Examples: rows.push([ d.deviceId, d.deviceName, - `[IR] ${d.remoteType}`, + d.remoteType, + 'ir', d.controlType || '—', inherited?.family || '—', inherited?.roomID || '—', @@ -115,12 +128,12 @@ Examples: ]); } - if (rows.length === 0) { + if (rows.length === 0 && fmt === 'table') { console.log('No devices found'); return; } - renderRows(headers, rows, fmt, resolveFields()); + renderRows(wideHeaders, rows, fmt, userFields ?? (options.wide ? undefined : narrowHeaders)); if (fmt === 'table') { console.log(`\nTotal: ${deviceList.length} physical device(s), ${infraredRemoteList.length} IR remote device(s)`); console.log(`Tip: 'switchbot devices describe ' shows a device's supported commands.`); @@ -136,33 +149,38 @@ Examples: .description('Query the real-time status of a specific device') .argument('', 'Device ID from "devices list" (physical devices only; IR remotes have no status)') .addHelpText('after', ` -Returned fields vary by device type — e.g. Bot returns power/battery, Meter -returns temperature/humidity/battery, Curtain returns slidePosition/moving, -Color Bulb returns brightness/color/colorTemperature, etc. - -To see exactly which status fields a given type returns BEFORE calling the -API, use the offline catalog: +Status fields vary by device type. To discover them without a live call: - switchbot devices commands (prints the "Status fields" section) + switchbot devices commands (prints the "Status fields" section) -IR remote devices cannot be queried — the SwitchBot API returns no status -channel for them. Use 'devices list' to confirm a deviceId is a physical -device (not in the 'infraredRemoteList'). +For --fields: run the command once with --format yaml (no --fields) to see +all field names returned by your specific device, then narrow with --fields. Examples: $ switchbot devices status ABC123DEF456 $ switchbot devices status ABC123DEF456 --json + $ switchbot devices status ABC123DEF456 --format yaml + $ switchbot devices status ABC123DEF456 --format tsv --fields power,battery $ switchbot devices status ABC123DEF456 --json | jq '.battery' `) .action(async (deviceId: string) => { try { const body = await fetchDeviceStatus(deviceId); + const fmt = resolveFormat(); - if (isJsonMode()) { + if (fmt === 'json' && process.argv.includes('--json')) { printJson(body); return; } + if (fmt !== 'table') { + const allHeaders = Object.keys(body); + const allRows = [Object.values(body) as unknown[]]; + const fields = resolveFields(); + renderRows(allHeaders, allRows, fmt, fields); + return; + } + printKeyValue(body); } catch (error) { handleError(error); @@ -227,17 +245,24 @@ Examples: const validation = validateCommand(deviceId, cmd, parameter, options.type); if (!validation.ok) { const err = validation.error; - 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.)` - ); + if (isJsonMode()) { + const obj: Record = { code: 2, kind: 'usage', message: err.message }; + if (err.hint) obj.hint = err.hint; + obj.context = { validationKind: err.kind }; + console.error(JSON.stringify({ error: 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.)` + ); + } } } process.exit(2); @@ -250,21 +275,24 @@ Examples: isDestructiveCommand(cachedForGuard?.type, cmd, options.type) ) { const typeLabel = cachedForGuard?.type ?? 'unknown'; + const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type); if (isJsonMode()) { - printJson({ + console.error(JSON.stringify({ error: { - code: 'destructive_requires_confirm', + code: 2, + kind: 'guard', message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`, - hint: `Re-run with --yes to confirm, or --dry-run to preview without sending.`, - deviceId, - command: cmd, - deviceType: typeLabel, + 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 } : {}) }, }, - }); + })); } else { console.error( `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.` ); + if (reason) console.error(`Reason: ${reason}`); console.error( `Re-run with --yes to confirm, or --dry-run to preview without sending.` ); @@ -317,22 +345,26 @@ Examples: $ switchbot devices types --json `) .action(() => { - const catalog = getEffectiveCatalog(); - const fmt = resolveFormat(); - if (fmt === 'json') { - printJson(catalog); - return; - } - const headers = ['type', 'category', 'commands', 'aliases']; - const rows = catalog.map((e) => [ - e.type, - e.category, - String(e.commands.length), - (e.aliases ?? []).join(', ') || '—', - ]); - renderRows(headers, rows, fmt, resolveFields()); - if (fmt === 'table') { - console.log(`\nTotal: ${catalog.length} device type(s)`); + try { + const catalog = getEffectiveCatalog(); + const fmt = resolveFormat(); + if (fmt === 'json') { + printJson(catalog); + return; + } + const headers = ['type', 'category', 'commands', 'aliases']; + const rows = catalog.map((e) => [ + e.type, + e.category, + String(e.commands.length), + (e.aliases ?? []).join(', ') || '—', + ]); + renderRows(headers, rows, fmt, resolveFields()); + if (fmt === 'table') { + console.log(`\nTotal: ${catalog.length} device type(s)`); + } + } catch (error) { + handleError(error); } }); @@ -358,22 +390,25 @@ Examples: `) .action((typeParts: string[]) => { const type = typeParts.join(' '); - const match = findCatalogEntry(type); - if (!match) { - console.error(`No device type matches "${type}".`); - console.error(`Try 'switchbot devices types' to see the full list.`); - process.exit(2); - } - if (Array.isArray(match)) { - console.error(`"${type}" matches multiple types. Be more specific:`); - for (const m of match) console.error(` • ${m.type}`); - process.exit(2); - } - if (isJsonMode()) { - printJson(match); - return; + try { + const match = findCatalogEntry(type); + if (!match) { + throw new UsageError( + `No device type matches "${type}". Try 'switchbot devices types' to see the full list.` + ); + } + if (Array.isArray(match)) { + const types = match.map((m) => m.type).join(', '); + throw new UsageError(`"${type}" matches multiple types: ${types}. Be more specific.`); + } + if (isJsonMode()) { + printJson(match); + return; + } + renderCatalogEntry(match); + } catch (error) { + handleError(error); } - renderCatalogEntry(match); }); // switchbot devices describe @@ -400,7 +435,7 @@ JSON output shape (--json): statusFields: [], liveStatus: }, - source: "catalog" | "live" | "catalog+live", + source: "catalog" | "live" | "catalog+live" | "none", suggestedActions: [{command, parameter?, description}] } diff --git a/src/commands/events.ts b/src/commands/events.ts index cb92d2e..f28c07f 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import http from 'node:http'; -import { printJson, isJsonMode } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; const DEFAULT_PORT = 3000; const DEFAULT_PATH = '/'; @@ -33,10 +33,27 @@ function matchFilter( function parseFilter(flag: string | undefined): { deviceId?: string; type?: string } | null { if (!flag) return null; + const allowed = new Set(['deviceId', 'type']); const out: { deviceId?: string; type?: string } = {}; for (const pair of flag.split(',')) { - const [k, v] = pair.split('=').map((s) => s.trim()); - if (!k || !v) continue; + const eq = pair.indexOf('='); + if (eq === -1 || eq === 0) { + throw new UsageError( + `Invalid --filter pair "${pair.trim()}". Expected "key=value". Supported keys: deviceId, type.` + ); + } + const k = pair.slice(0, eq).trim(); + const v = pair.slice(eq + 1).trim(); + if (!v) { + throw new UsageError( + `Empty value for --filter key "${k}". Expected "key=value". Supported keys: deviceId, type.` + ); + } + if (!allowed.has(k)) { + throw new UsageError( + `Unknown --filter key "${k}". Supported keys: deviceId, type.` + ); + } if (k === 'deviceId') out.deviceId = v; else if (k === 'type') out.type = v; } @@ -140,52 +157,54 @@ Examples: `, ) .action(async (options: { port: string; path: string; filter?: string; max?: string }) => { - const port = Number(options.port); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - console.error(`Invalid --port "${options.port}". Must be 1..65535.`); - process.exit(2); - } - const maxMatched: number | null = options.max !== undefined ? Number(options.max) : null; - if (maxMatched !== null && (!Number.isFinite(maxMatched) || maxMatched < 1)) { - console.error(`Invalid --max "${options.max}". Must be a positive integer.`); - process.exit(2); - } - const filter = parseFilter(options.filter); - - let matchedCount = 0; - const ac = new AbortController(); - await new Promise((resolve, reject) => { - let server: http.Server | null = null; - try { - server = startReceiver(port, options.path, filter, (ev) => { - if (!ev.matched) return; - matchedCount++; - if (isJsonMode()) { - printJson(ev); - } else { - const when = new Date(ev.t).toLocaleTimeString(); - console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`); - } - if (maxMatched !== null && matchedCount >= maxMatched) { - ac.abort(); - } - }); - server.on('error', (err) => reject(err)); - } catch (err) { - reject(err); - return; + try { + const port = Number(options.port); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + throw new UsageError(`Invalid --port "${options.port}". Must be 1..65535.`); + } + const maxMatched: number | null = options.max !== undefined ? Number(options.max) : null; + if (maxMatched !== null && (!Number.isFinite(maxMatched) || maxMatched < 1)) { + throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`); } + const filter = parseFilter(options.filter); - const startMsg = `Listening on http://127.0.0.1:${port}${options.path} (Ctrl-C to stop)`; - if (!isJsonMode()) console.error(startMsg); + let matchedCount = 0; + const ac = new AbortController(); + await new Promise((resolve, reject) => { + let server: http.Server | null = null; + try { + server = startReceiver(port, options.path, filter, (ev) => { + if (!ev.matched) return; + matchedCount++; + if (isJsonMode()) { + printJson(ev); + } else { + const when = new Date(ev.t).toLocaleTimeString(); + console.log(`[${when}] ${ev.remote} ${ev.path} ${JSON.stringify(ev.body)}`); + } + if (maxMatched !== null && matchedCount >= maxMatched) { + ac.abort(); + } + }); + server.on('error', (err) => reject(err)); + } catch (err) { + reject(err); + return; + } - const cleanup = () => { - server?.close(); - resolve(); - }; - process.once('SIGINT', cleanup); - process.once('SIGTERM', cleanup); - ac.signal.addEventListener('abort', cleanup, { once: true }); - }); + const startMsg = `Listening on http://127.0.0.1:${port}${options.path} (Ctrl-C to stop)`; + if (!isJsonMode()) console.error(startMsg); + + const cleanup = () => { + server?.close(); + resolve(); + }; + process.once('SIGINT', cleanup); + process.once('SIGTERM', cleanup); + ac.signal.addEventListener('abort', cleanup, { once: true }); + }); + } catch (error) { + handleError(error); + } }); } diff --git a/src/commands/explain.ts b/src/commands/explain.ts index 66b59ac..513c56b 100644 --- a/src/commands/explain.ts +++ b/src/commands/explain.ts @@ -3,7 +3,6 @@ import { printJson, isJsonMode, handleError } from '../utils/output.js'; import { describeDevice, fetchDeviceList, - DeviceNotFoundError, type Device, type InfraredDevice, } from '../lib/devices.js'; @@ -113,14 +112,6 @@ Examples: } printHuman(result); } catch (err) { - if (err instanceof DeviceNotFoundError) { - if (isJsonMode()) { - printJson({ error: { code: 'device_not_found', message: err.message, deviceId } }); - } else { - console.error(err.message); - } - process.exit(1); - } handleError(err); } }); diff --git a/src/commands/history.ts b/src/commands/history.ts index fcd9bc0..cc4ca58 100644 --- a/src/commands/history.ts +++ b/src/commands/history.ts @@ -74,12 +74,22 @@ Examples: const entries = readAudit(file); const idx = Number(indexArg); if (!Number.isInteger(idx) || idx < 1 || idx > entries.length) { - console.error(`Invalid index ${indexArg}. Log has ${entries.length} entries.`); + const msg = `Invalid index ${indexArg}. Log has ${entries.length} entries.`; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } process.exit(2); } const entry: AuditEntry = entries[idx - 1]; if (entry.kind !== 'command') { - console.error(`Entry ${idx} is not a command (kind=${entry.kind}).`); + const msg = `Entry ${idx} is not a command (kind=${entry.kind}).`; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } process.exit(2); } try { diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index 1145a71..fd4b1d9 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -1,8 +1,9 @@ import { Command } from 'commander'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { z } from 'zod'; -import { handleError } from '../utils/output.js'; +import { handleError, isJsonMode } from '../utils/output.js'; import { fetchDeviceList, fetchDeviceStatus, @@ -10,8 +11,12 @@ import { describeDevice, validateCommand, isDestructiveCommand, + getDestructiveReason, searchCatalog, DeviceNotFoundError, + toMcpDescribeShape, + toMcpDeviceListShape, + toMcpIrDeviceShape, } from '../lib/devices.js'; import { fetchScenes, executeScene } from '../lib/scenes.js'; import { findCatalogEntry } from '../devices/catalog.js'; @@ -21,6 +26,25 @@ import { getCachedDevice } from '../devices/cache.js'; * Factory — build an McpServer with the six SwitchBot tools registered. * Exported so tests and alternative transports can reuse it. */ + +type McpErrorKind = 'api' | 'runtime' | 'usage' | 'guard'; + +function mcpError( + kind: McpErrorKind, + code: number, + message: string, + options?: { hint?: string; retryable?: boolean; context?: Record }, +) { + const obj: Record = { code, kind, message }; + if (options?.hint) obj.hint = options.hint; + if (options?.retryable) obj.retryable = true; + if (options?.context) obj.context = options.context; + return { + isError: true as const, + content: [{ type: 'text' as const, text: JSON.stringify({ error: obj }, null, 2) }], + }; +} + export function createSwitchBotMcpServer(): McpServer { const server = new McpServer( { @@ -30,7 +54,25 @@ export function createSwitchBotMcpServer(): McpServer { { capabilities: { tools: {} }, instructions: - 'SwitchBot device control. Before issuing a command with destructive effects (e.g. unlock, garage open, keypad createKey), pass confirm:true. Use search_catalog to discover what a device type supports offline; use describe_device to fetch live capabilities for a specific deviceId.', + `SwitchBot is an IoT smart home brand by Wonderlabs, Inc. This MCP server controls physical devices \ +(Bot, Curtain, Smart Lock, Color Bulb, Meter, Plug, Robot Vacuum, etc.) and IR remotes \ +(TV, AC, Set Top Box, etc.) via the SwitchBot Cloud API v1.1. + +Device categories: +- physical: Wi-Fi/BLE devices; BLE-only ones require a Hub (check enableCloudService) +- ir: IR remotes learned by a Hub; no status channel, commands only + +Key constraints: +- API quota: 10,000 requests/day per account — use cache, avoid polling +- Destructive commands (unlock, garage open, keypad createKey/deleteKey) require confirm:true +- Devices without enableCloudService cannot receive commands via API + +Recommended bootstrap sequence: +1. list_devices → get deviceIds and categories +2. search_catalog or describe_device → confirm supported commands offline/online +3. send_command (with confirm:true for destructive commands) + +API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`, } ); @@ -42,16 +84,35 @@ export function createSwitchBotMcpServer(): McpServer { description: 'Fetch the inventory of physical devices and IR remotes on this SwitchBot account. Refreshes the local cache.', inputSchema: {}, + outputSchema: { + deviceList: z.array(z.object({ + deviceId: z.string(), + deviceName: z.string(), + deviceType: z.string().optional(), + enableCloudService: z.boolean(), + hubDeviceId: z.string(), + roomID: z.string().optional(), + roomName: z.string().nullable().optional(), + familyName: z.string().optional(), + controlType: z.string().optional(), + }).passthrough()).describe('Physical SwitchBot devices'), + infraredRemoteList: z.array(z.object({ + deviceId: z.string(), + deviceName: z.string(), + remoteType: z.string(), + hubDeviceId: z.string(), + controlType: z.string().optional(), + }).passthrough()).describe('IR remote devices'), + }, }, async () => { const body = await fetchDeviceList(); return { - content: [ - { - type: 'text', - text: JSON.stringify(body, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], + structuredContent: { + deviceList: body.deviceList.map(toMcpDeviceListShape), + infraredRemoteList: body.infraredRemoteList.map(toMcpIrDeviceShape), + }, }; } ); @@ -66,16 +127,20 @@ export function createSwitchBotMcpServer(): McpServer { inputSchema: { deviceId: z.string().describe('Device ID from list_devices'), }, + outputSchema: { + status: z.object({ + deviceId: z.string().optional(), + deviceType: z.string().optional(), + hubDeviceId: z.string().optional(), + connectionStatus: z.string().optional(), + }).passthrough().describe('Live device status (deviceId + deviceType + device-specific fields)'), + }, }, async ({ deviceId }) => { const body = await fetchDeviceStatus(deviceId); return { - content: [ - { - type: 'text', - text: JSON.stringify(body, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(body, null, 2) }], + structuredContent: { status: body as { deviceId?: string; deviceType?: string; [key: string]: unknown } }, }; } ); @@ -105,6 +170,12 @@ export function createSwitchBotMcpServer(): McpServer { .default(false) .describe('Required true for destructive commands (unlock, garage open, createKey, ...)'), }, + outputSchema: { + ok: z.literal(true), + command: z.string(), + deviceId: z.string(), + result: z.unknown().describe('API response body from SwitchBot'), + }, }, async ({ deviceId, command, parameter, commandType, confirm }) => { const effectiveType = commandType ?? 'command'; @@ -118,40 +189,36 @@ export function createSwitchBotMcpServer(): McpServer { const physical = body.deviceList.find((d) => d.deviceId === deviceId); const ir = body.infraredRemoteList.find((d) => d.deviceId === deviceId); if (!physical && !ir) { - return { - isError: true, - content: [{ type: 'text', text: `Device not found: ${deviceId}` }], - }; + return mcpError('runtime', 152, `Device not found: ${deviceId}`, { + hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).", + }); } typeName = physical ? physical.deviceType : ir!.remoteType; } if (isDestructiveCommand(typeName, command, effectiveType) && !confirm) { + const reason = getDestructiveReason(typeName, command, effectiveType); const entry = typeName ? findCatalogEntry(typeName) : null; const spec = entry && !Array.isArray(entry) ? entry.commands.find((c) => c.command === command) : undefined; - return { - isError: true, - content: [ - { - type: 'text', - text: JSON.stringify( - { - error: 'destructive_requires_confirm', - message: `Command "${command}" on device type "${typeName}" is destructive and requires confirm:true.`, - command, - deviceType: typeName, - description: spec?.description, - hint: 'Re-issue the call with confirm:true to proceed.', - }, - null, - 2 - ), + const hint = reason + ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}` + : 'Re-issue the call with confirm:true to proceed.'; + return mcpError( + 'guard', 3, + `Command "${command}" on device type "${typeName}" is destructive and requires confirm:true.`, + { + hint, + context: { + command, + deviceType: typeName, + description: spec?.description ?? null, + ...(reason ? { destructiveReason: reason } : {}), }, - ], - }; + }, + ); } // stringifiedParam is what validateCommand expects to decide @@ -160,43 +227,18 @@ export function createSwitchBotMcpServer(): McpServer { parameter === undefined ? undefined : typeof parameter === 'string' ? parameter : JSON.stringify(parameter); const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType); if (!validation.ok) { - return { - isError: true, - content: [ - { - type: 'text', - text: JSON.stringify( - { - error: 'validation_failed', - message: validation.error.message, - kind: validation.error.kind, - hint: validation.error.hint, - }, - null, - 2 - ), - }, - ], - }; + return mcpError( + 'usage', 2, + validation.error.message, + { hint: validation.error.hint, context: { validationKind: validation.error.kind } }, + ); } const result = await executeCommand(deviceId, command, parameter, effectiveType); + const structured = { ok: true as const, command, deviceId, result }; return { - content: [ - { - type: 'text', - text: JSON.stringify( - { - ok: true, - command, - deviceId, - result, - }, - null, - 2 - ), - }, - ], + content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], + structuredContent: structured, }; } ); @@ -210,16 +252,17 @@ export function createSwitchBotMcpServer(): McpServer { inputSchema: { sceneId: z.string().describe('Scene ID from list_scenes'), }, + outputSchema: { + ok: z.literal(true), + sceneId: z.string(), + }, }, async ({ sceneId }) => { await executeScene(sceneId); + const structured = { ok: true as const, sceneId }; return { - content: [ - { - type: 'text', - text: JSON.stringify({ ok: true, sceneId }, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], + structuredContent: structured, }; } ); @@ -231,16 +274,15 @@ export function createSwitchBotMcpServer(): McpServer { title: 'List all manual scenes', description: 'Fetch all manual scenes configured in the SwitchBot app.', inputSchema: {}, + outputSchema: { + scenes: z.array(z.object({ sceneId: z.string(), sceneName: z.string() })), + }, }, async () => { const scenes = await fetchScenes(); return { - content: [ - { - type: 'text', - text: JSON.stringify(scenes, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(scenes, null, 2) }], + structuredContent: { scenes }, }; } ); @@ -256,16 +298,32 @@ export function createSwitchBotMcpServer(): McpServer { query: z.string().describe('Search query (matches type and aliases, case-insensitive). Use empty string to list all.'), limit: z.number().int().min(1).max(100).optional().default(20).describe('Max entries returned (default 20)'), }, + outputSchema: { + results: z.array(z.object({ + type: z.string(), + category: z.enum(['physical', 'ir']), + commands: z.array(z.object({ + command: z.string(), + parameter: z.string(), + description: z.string(), + commandType: z.enum(['command', 'customize']).optional(), + idempotent: z.boolean().optional(), + destructive: z.boolean().optional(), + }).passthrough()), + aliases: z.array(z.string()).optional(), + statusFields: z.array(z.string()).optional(), + role: z.string().optional(), + readOnly: z.boolean().optional(), + }).passthrough()).describe('Matching catalog entries'), + total: z.number().int().describe('Number of entries returned'), + }, }, async ({ query, limit }) => { const hits = searchCatalog(query, limit); + const structured = { results: hits as unknown as Array>, total: hits.length }; return { - content: [ - { - type: 'text', - text: JSON.stringify(hits, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }], + structuredContent: structured, }; } ); @@ -281,24 +339,39 @@ export function createSwitchBotMcpServer(): McpServer { deviceId: z.string().describe('Device ID from list_devices'), live: z.boolean().optional().default(false).describe('Also fetch live /status values (costs 1 extra API call)'), }, + outputSchema: { + device: z.object({ + device: z.object({ deviceId: z.string(), deviceName: z.string() }).passthrough(), + isPhysical: z.boolean(), + typeName: z.string(), + controlType: z.string().nullable(), + source: z.enum(['catalog', 'live', 'catalog+live', 'none']), + capabilities: z.unknown().nullable(), + suggestedActions: z.array(z.object({ + command: z.string(), + parameter: z.string().optional(), + description: z.string(), + })).optional(), + inheritedLocation: z.object({ + family: z.string().optional(), + room: z.string().optional(), + }).optional(), + }).passthrough().describe('Device metadata, catalog entry, capabilities, and optional live status'), + }, }, async ({ deviceId, live }) => { try { const result = await describeDevice(deviceId, { live }); return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: { device: toMcpDescribeShape(result) }, }; } catch (err) { if (err instanceof DeviceNotFoundError) { - return { - isError: true, - content: [{ type: 'text', text: err.message }], - }; + return mcpError('runtime', 152, err.message, { + hint: "Check the deviceId with 'switchbot devices list' (IDs are case-sensitive).", + context: { deviceId }, + }); } throw err; } @@ -343,13 +416,51 @@ Inspect locally: mcp .command('serve') - .description('Start the MCP server on stdio') - .action(async () => { + .description('Start the MCP server on stdio (default) or HTTP (--port)') + .option('--port ', 'Listen on HTTP instead of stdio (Streamable HTTP transport)') + .action(async (options: { port?: string }) => { try { + if (options.port) { + const port = Number(options.port); + if (!Number.isFinite(port) || port < 1 || port > 65535) { + const msg = `Invalid --port "${options.port}". Must be 1-65535.`; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } + process.exit(2); + } + const { createServer } = await import('node:http'); + const httpServer = createServer(async (req, res) => { + // Stateless mode: fresh transport+server per request (SDK requirement). + const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const reqServer = createSwitchBotMcpServer(); + // Register cleanup before any async work so it fires on both normal + // close and error-path close (after the 500 response ends). + res.on('close', () => { + reqTransport.close(); + reqServer.close(); + }); + try { + await reqServer.connect(reqTransport); + await reqTransport.handleRequest(req, res); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })); + } + } + }); + httpServer.listen(port, () => { + console.error(`SwitchBot MCP server listening on http://localhost:${port}/mcp`); + }); + return; + } + const server = createSwitchBotMcpServer(); const transport = new StdioServerTransport(); await server.connect(transport); - // stdio transport keeps the process alive; return without exiting. } catch (error) { handleError(error); } diff --git a/src/commands/scenes.ts b/src/commands/scenes.ts index 3d847cc..72d011a 100644 --- a/src/commands/scenes.ts +++ b/src/commands/scenes.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printJson, handleError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError } from '../utils/output.js'; import { resolveFormat, resolveFields, renderRows } from '../utils/format.js'; import { fetchScenes, executeScene } from '../lib/scenes.js'; @@ -14,9 +14,12 @@ export function registerScenesCommand(program: Command): void { .description('List all manual scenes (scenes created in the SwitchBot app)') .addHelpText('after', ` Output columns: sceneId, sceneName +--fields accepts any subset of these names (exit 2 on unknown names). Examples: $ switchbot scenes list + $ switchbot scenes list --format tsv --fields sceneId,sceneName + $ switchbot scenes list --format id $ switchbot scenes list --json `) .action(async () => { @@ -24,22 +27,20 @@ Examples: const scenes = await fetchScenes(); const fmt = resolveFormat(); - if (fmt === 'json') { + if (fmt === 'json' && process.argv.includes('--json')) { printJson(scenes); return; } - if (scenes.length === 0) { - console.log('No scenes found'); - return; - } - renderRows( ['sceneId', 'sceneName'], scenes.map((s) => [s.sceneId, s.sceneName]), fmt, resolveFields(), ); + if (fmt === 'table' && scenes.length === 0) { + console.log('No scenes found'); + } } catch (error) { handleError(error); } @@ -57,7 +58,11 @@ Example: .action(async (sceneId: string) => { try { await executeScene(sceneId); - console.log(`✓ Scene executed: ${sceneId}`); + if (isJsonMode()) { + printJson({ ok: true, sceneId }); + } else { + console.log(`✓ Scene executed: ${sceneId}`); + } } catch (error) { handleError(error); } diff --git a/src/commands/schema.ts b/src/commands/schema.ts index 4986688..ea9fcac 100644 --- a/src/commands/schema.ts +++ b/src/commands/schema.ts @@ -4,6 +4,7 @@ import { getEffectiveCatalog, type CommandSpec, type DeviceCatalogEntry } from ' interface SchemaEntry { type: string; + description: string; category: 'physical' | 'ir'; aliases: string[]; role: string | null; @@ -23,6 +24,7 @@ interface SchemaEntry { function toSchemaEntry(e: DeviceCatalogEntry): SchemaEntry { return { type: e.type, + description: e.description ?? '', category: e.category, aliases: e.aliases ?? [], role: e.role ?? null, @@ -47,28 +49,43 @@ function toSchemaCommand(c: CommandSpec) { export function registerSchemaCommand(program: Command): void { const schema = program .command('schema') - .description('Dump the device catalog as machine-readable JSON Schema (for agent prompt / docs)'); + .description('Export the device catalog as structured JSON (for agent prompts / tooling)'); schema .command('export') - .description('Print the full catalog as JSON (one object per type)') - .option('--type ', 'Restrict to a single type (e.g. "Strip Light")') + .description('Print the full catalog as structured JSON (one object per type)') + .option('--type ', 'Restrict to a single device type (e.g. "Strip Light")') + .option('--role ', 'Restrict to a functional role: lighting, security, sensor, climate, media, cleaning, curtain, fan, power, hub, other') + .option('--category ', 'Restrict to "physical" or "ir"') .addHelpText('after', ` -Output is always JSON (this command ignores --format). Use 'schema export' to -pre-bake a prompt for an LLM, or to regenerate docs when the catalog bumps. +Output is always JSON (this command ignores --format). The output is a +catalog export — not a formal JSON Schema standard document — suitable for +pre-baking LLM prompts or regenerating docs when the catalog changes. Examples: $ switchbot schema export > catalog.json $ switchbot schema export --type Bot | jq '.types[0].commands' + $ switchbot schema export --role lighting | jq '[.types[].type]' + $ switchbot schema export --role security --category physical `) - .action((options: { type?: string }) => { + .action((options: { type?: string; role?: string; category?: string }) => { const catalog = getEffectiveCatalog(); - const filtered = options.type - ? catalog.filter((e) => - e.type.toLowerCase() === options.type!.toLowerCase() || - (e.aliases ?? []).some((a) => a.toLowerCase() === options.type!.toLowerCase()), - ) - : catalog; + let filtered = catalog; + if (options.type) { + const q = options.type.toLowerCase(); + filtered = filtered.filter((e) => + e.type.toLowerCase() === q || + (e.aliases ?? []).some((a) => a.toLowerCase() === q), + ); + } + if (options.role) { + const q = options.role.toLowerCase(); + filtered = filtered.filter((e) => (e.role ?? 'other') === q); + } + if (options.category) { + const q = options.category.toLowerCase(); + filtered = filtered.filter((e) => e.category === q); + } const payload = { version: '1.0', generatedAt: new Date().toISOString(), diff --git a/src/commands/watch.ts b/src/commands/watch.ts index a643e37..2241428 100644 --- a/src/commands/watch.ts +++ b/src/commands/watch.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { printJson, isJsonMode, handleError } from '../utils/output.js'; +import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import { fetchDeviceStatus } from '../lib/devices.js'; import { getCachedDevice } from '../devices/cache.js'; import { parseDurationToMs, getFields } from '../utils/flags.js'; @@ -102,33 +102,32 @@ Examples: includeUnchanged?: boolean; }, ) => { - const parsed = parseDurationToMs(options.interval); - if (parsed === null || parsed < MIN_INTERVAL_MS) { - console.error( - `Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`, - ); - process.exit(2); - } - const intervalMs = parsed; + try { + const parsed = parseDurationToMs(options.interval); + if (parsed === null || parsed < MIN_INTERVAL_MS) { + throw new UsageError( + `Invalid --interval "${options.interval}". Minimum is ${MIN_INTERVAL_MS / 1000}s.`, + ); + } + const intervalMs = parsed; - let maxTicks: number | null = null; - if (options.max !== undefined) { - const n = Number(options.max); - if (!Number.isFinite(n) || n < 1) { - console.error(`Invalid --max "${options.max}". Must be a positive integer.`); - process.exit(2); + let maxTicks: number | null = null; + if (options.max !== undefined) { + const n = Number(options.max); + if (!Number.isFinite(n) || n < 1) { + throw new UsageError(`Invalid --max "${options.max}". Must be a positive integer.`); + } + maxTicks = Math.floor(n); } - maxTicks = Math.floor(n); - } - const fields: string[] | null = getFields() ?? null; + const fields: string[] | null = getFields() ?? null; - const ac = new AbortController(); - const onSig = () => ac.abort(); - process.on('SIGINT', onSig); - process.on('SIGTERM', onSig); + const ac = new AbortController(); + const onSig = () => ac.abort(); + process.on('SIGINT', onSig); + process.on('SIGTERM', onSig); - try { + try { const prev = new Map>(); let tick = 0; while (!ac.signal.aborted) { @@ -180,11 +179,14 @@ Examples: if (maxTicks !== null && tick >= maxTicks) break; await sleep(intervalMs, ac.signal); } - } catch (err) { - handleError(err); - } finally { - process.off('SIGINT', onSig); - process.off('SIGTERM', onSig); + } catch (err) { + handleError(err); + } finally { + process.off('SIGINT', onSig); + process.off('SIGTERM', onSig); + } + } catch (error) { + handleError(error); } }, ); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 0d40fad..e49b4e3 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { createClient } from '../api/client.js'; -import { printKeyValue, printJson, isJsonMode, handleError } from '../utils/output.js'; +import { printKeyValue, printJson, isJsonMode, handleError, UsageError } from '../utils/output.js'; import chalk from 'chalk'; interface WebhookDetails { @@ -11,20 +11,18 @@ interface WebhookDetails { enable: boolean; } -function assertValidUrl(cmd: Command, url: string): void { +function assertValidUrl(url: string): void { let parsed: URL; try { parsed = new URL(url); } catch { - cmd.error( - `error: invalid URL "${url}" (expected absolute URL, e.g. https://example.com/hook)`, - { exitCode: 2, code: 'switchbot.invalidUrl' } + throw new UsageError( + `Invalid URL "${url}" (expected absolute URL, e.g. https://example.com/hook)`, ); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - cmd.error( - `error: URL must use http:// or https:// (got "${parsed.protocol}")`, - { exitCode: 2, code: 'switchbot.invalidUrl' } + throw new UsageError( + `URL must use http:// or https:// (got "${parsed.protocol}")`, ); } } @@ -39,24 +37,28 @@ Only one webhook URL can be active per account; "setup" registers it for ALL dev `); // switchbot webhook setup - const setup = webhook + webhook .command('setup') .description('Configure the webhook receiver URL (receives events from all devices)') .argument('', 'Absolute http(s):// URL where SwitchBot will POST events') .addHelpText('after', ` Example: $ switchbot webhook setup https://example.com/switchbot/events -`); - setup.action(async (url: string) => { - assertValidUrl(setup, url); +`) + .action(async (url: string) => { try { + assertValidUrl(url); const client = createClient(); await client.post('/v1.1/webhook/setupWebhook', { action: 'setupWebhook', url, deviceList: 'ALL', }); - console.log(chalk.green(`✓ Webhook configured: ${url}`)); + if (isJsonMode()) { + printJson({ ok: true, url }); + } else { + console.log(chalk.green(`✓ Webhook configured: ${url}`)); + } } catch (error) { handleError(error); } @@ -132,7 +134,7 @@ Examples: }); // switchbot webhook update [--enable | --disable] - const update = webhook + webhook .command('update') .description('Update webhook configuration (enable / disable a registered URL)') .argument('', 'URL of the webhook to update (must already be configured)') @@ -145,16 +147,13 @@ webhook is re-submitted with no change to its enabled state. Examples: $ switchbot webhook update https://example.com/hook --enable $ switchbot webhook update https://example.com/hook --disable -`); - update.action(async (url: string, options: { enable?: boolean; disable?: boolean }) => { - if (options.enable && options.disable) { - update.error( - 'error: --enable and --disable are mutually exclusive', - { exitCode: 2, code: 'switchbot.conflictingOptions' } - ); - } - assertValidUrl(update, url); +`) + .action(async (url: string, options: { enable?: boolean; disable?: boolean }) => { try { + if (options.enable && options.disable) { + throw new UsageError('--enable and --disable are mutually exclusive'); + } + assertValidUrl(url); const client = createClient(); const config: { url: string; enable?: boolean } = { url }; @@ -167,30 +166,38 @@ Examples: }); const statusText = options.enable ? 'enabled' : options.disable ? 'disabled' : 'updated'; - console.log(chalk.green(`✓ Webhook ${statusText}: ${url}`)); + if (isJsonMode()) { + printJson({ ok: true, url, status: statusText }); + } else { + console.log(chalk.green(`✓ Webhook ${statusText}: ${url}`)); + } } catch (error) { handleError(error); } }); // switchbot webhook delete - const del = webhook + webhook .command('delete') .description('Delete webhook configuration') .argument('', 'URL of the webhook to remove') .addHelpText('after', ` Example: $ switchbot webhook delete https://example.com/hook -`); - del.action(async (url: string) => { - assertValidUrl(del, url); +`) + .action(async (url: string) => { try { + assertValidUrl(url); const client = createClient(); await client.post('/v1.1/webhook/deleteWebhook', { action: 'deleteWebhook', url, }); - console.log(chalk.green(`✓ Webhook deleted: ${url}`)); + if (isJsonMode()) { + printJson({ ok: true, url }); + } else { + console.log(chalk.green(`✓ Webhook deleted: ${url}`)); + } } catch (error) { handleError(error); } diff --git a/src/devices/cache.ts b/src/devices/cache.ts index 354a6bb..79cf4ec 100644 --- a/src/devices/cache.ts +++ b/src/devices/cache.ts @@ -3,10 +3,19 @@ import path from 'node:path'; import os from 'node:os'; import { getConfigPath } from '../utils/flags.js'; +/** GC cutoff for status entries: evict anything older than this. */ +const DEFAULT_STATUS_GC_TTL_MS = 24 * 60 * 60 * 1000; // 24 h + export interface CachedDevice { type: string; name: string; category: 'physical' | 'ir'; + hubDeviceId?: string; + enableCloudService?: boolean; + roomID?: string; + roomName?: string | null; + familyName?: string; + controlType?: string; } export interface DeviceCache { @@ -19,11 +28,19 @@ export interface DeviceListBodyShape { deviceId: string; deviceName: string; deviceType?: string; + hubDeviceId?: string; + enableCloudService?: boolean; + roomID?: string; + roomName?: string | null; + familyName?: string; + controlType?: string; }>; infraredRemoteList: Array<{ deviceId: string; deviceName: string; remoteType: string; + hubDeviceId?: string; + controlType?: string; }>; } @@ -35,17 +52,32 @@ function cacheFilePath(): string { return path.join(dir, 'devices.json'); } +// In-memory hot-cache: undefined = not yet loaded, null = loaded but empty. +let _listCache: DeviceCache | null | undefined = undefined; + +/** Force the next loadCache() call to re-read from disk. Used in tests. */ +export function resetListCache(): void { + _listCache = undefined; +} + export function loadCache(): DeviceCache | null { + if (_listCache !== undefined) return _listCache; const file = cacheFilePath(); - if (!fs.existsSync(file)) return null; + if (!fs.existsSync(file)) { + _listCache = null; + return null; + } try { const raw = fs.readFileSync(file, 'utf-8'); const cache = JSON.parse(raw) as DeviceCache; if (!cache || typeof cache.devices !== 'object' || cache.devices === null) { + _listCache = null; return null; } + _listCache = cache; return cache; } catch { + _listCache = null; return null; } } @@ -65,6 +97,12 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { type: d.deviceType, name: d.deviceName, category: 'physical', + hubDeviceId: d.hubDeviceId, + enableCloudService: d.enableCloudService, + roomID: d.roomID, + roomName: d.roomName, + familyName: d.familyName, + controlType: d.controlType, }; } for (const d of body.infraredRemoteList) { @@ -73,6 +111,8 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { type: d.remoteType, name: d.deviceName, category: 'ir', + hubDeviceId: d.hubDeviceId, + controlType: d.controlType, }; } @@ -86,6 +126,7 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { const dir = path.dirname(file); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 0o600 }); + _listCache = cache; } catch { // Cache write failures must not break the command that triggered them. } @@ -94,6 +135,7 @@ export function updateCacheFromDeviceList(body: DeviceListBodyShape): void { export function clearCache(): void { const file = cacheFilePath(); if (fs.existsSync(file)) fs.unlinkSync(file); + _listCache = null; } // ---- Device list freshness ------------------------------------------------- @@ -183,6 +225,16 @@ export function getCachedStatus( return entry.body; } +/** Evict status entries older than max(ttlMs × 10, 24 h) to bound file growth. */ +function evictExpiredStatusEntries(cache: StatusCache, ttlMs: number, now = Date.now()): void { + const cutoff = now - Math.max(ttlMs * 10, 24 * 60 * 60 * 1000); + for (const id of Object.keys(cache.entries)) { + const entry = cache.entries[id]; + const ts = Date.parse(entry.fetchedAt); + if (!Number.isFinite(ts) || ts < cutoff) delete cache.entries[id]; + } +} + export function setCachedStatus( deviceId: string, body: Record, @@ -193,6 +245,7 @@ export function setCachedStatus( fetchedAt: now.toISOString(), body, }; + evictExpiredStatusEntries(cache, DEFAULT_STATUS_GC_TTL_MS, now.getTime()); saveStatusCache(cache); } diff --git a/src/devices/catalog.ts b/src/devices/catalog.ts index 519e4da..5537b57 100644 --- a/src/devices/catalog.ts +++ b/src/devices/catalog.ts @@ -23,6 +23,8 @@ export interface CommandSpec { commandType?: 'command' | 'customize'; idempotent?: boolean; destructive?: boolean; + /** One sentence explaining *why* this command is destructive — used in guard errors so agents/users can decide whether to confirm. */ + destructiveReason?: string; exampleParams?: string[]; } @@ -43,6 +45,7 @@ export type DeviceRole = export interface DeviceCatalogEntry { type: string; category: 'physical' | 'ir'; + description?: string; aliases?: string[]; commands: CommandSpec[]; statusFields?: string[]; @@ -71,6 +74,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Bot', category: 'physical', + description: 'Mechanical arm robot that physically presses a button or toggles a switch on demand.', role: 'other', commands: [ ...onOff, @@ -81,6 +85,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Curtain', category: 'physical', + description: 'Motorized curtain track runner that opens/closes curtains by slide position (0=open, 100=closed).', role: 'curtain', aliases: ['Curtain3', 'Curtain 3'], commands: [ @@ -94,11 +99,12 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Smart Lock', category: 'physical', + description: 'Bluetooth/Wi-Fi electronic deadbolt that locks and unlocks a door via cloud API.', role: 'security', 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 }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: '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'], @@ -106,20 +112,22 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Smart Lock Lite', category: 'physical', + description: 'Compact electronic deadbolt with lock and unlock control; no deadbolt mode.', role: 'security', commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, - { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], }, { type: 'Smart Lock Ultra', category: 'physical', + description: 'Premium electronic deadbolt with full lock, unlock, and deadbolt control.', role: 'security', commands: [ { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true }, - { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true }, + { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' }, { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true }, ], statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'], @@ -127,6 +135,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Plug', category: 'physical', + description: 'Smart wall outlet plug with on/off/toggle control and basic power status.', role: 'power', commands: onOffToggle, statusFields: ['power', 'version'], @@ -134,6 +143,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Plug Mini (US)', category: 'physical', + description: 'Compact smart plug with voltage, current, and daily energy consumption reporting.', role: 'power', aliases: ['Plug Mini (JP)'], commands: onOffToggle, @@ -142,6 +152,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Relay Switch 1', category: 'physical', + description: 'In-wall relay switch with configurable modes (toggle/edge/detached/momentary) and power metering.', role: 'power', aliases: ['Relay Switch 1PM'], commands: [ @@ -153,6 +164,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Relay Switch 2PM', category: 'physical', + description: 'Dual-channel relay switch with per-channel on/off/toggle and optional roller-shade mode.', role: 'power', commands: [ { command: 'turnOn', parameter: '1 | 2 (channel)', description: 'Turn on channel 1 or 2', idempotent: true, exampleParams: ['1', '2'] }, @@ -166,6 +178,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Humidifier', category: 'physical', + description: 'Ultrasonic humidifier with auto and preset humidity level control.', role: 'climate', commands: [ ...onOff, @@ -176,6 +189,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Humidifier2', category: 'physical', + description: 'Evaporative humidifier with multiple speed/auto/sleep/humidity modes and child lock.', role: 'climate', aliases: ['Evaporative Humidifier'], commands: [ @@ -188,6 +202,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Air Purifier VOC', category: 'physical', + description: 'HEPA air purifier with VOC or PM2.5 sensing, multiple fan modes, and child lock.', role: 'climate', aliases: ['Air Purifier PM2.5', 'Air Purifier Table VOC', 'Air Purifier Table PM2.5'], commands: [ @@ -200,6 +215,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Color Bulb', category: 'physical', + description: 'Wi-Fi smart bulb with tunable brightness, RGB color, and color temperature.', role: 'lighting', commands: [...onOffToggle, ...lightControls], statusFields: ['power', 'brightness', 'color', 'colorTemperature', 'version'], @@ -207,6 +223,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Strip Light', category: 'physical', + description: 'Addressable LED strip with on/off, brightness, RGB color, and color temperature control.', role: 'lighting', aliases: ['Strip Light 3'], commands: [...onOffToggle, ...lightControls], @@ -215,6 +232,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Ceiling Light', category: 'physical', + description: 'Smart ceiling fixture with brightness and color-temperature adjustment (no RGB).', role: 'lighting', aliases: ['Ceiling Light Pro'], commands: [ @@ -227,6 +245,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Smart Radiator Thermostat', category: 'physical', + description: 'Motorized thermostatic valve for radiators with schedule, manual, eco, and comfort modes.', role: 'climate', commands: [ ...onOff, @@ -238,6 +257,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Robot Vacuum Cleaner S1', category: 'physical', + description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.', role: 'cleaning', aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'], commands: [ @@ -251,6 +271,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'K10+ Pro Combo', category: 'physical', + description: 'Compact robot vacuum and mop combo with sweep/mop sessions, fan level, and water level.', role: 'cleaning', aliases: ['K20+ Pro'], commands: [ @@ -265,6 +286,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Floor Cleaning Robot S10', category: 'physical', + description: 'Advanced floor cleaning robot with sweep/mop modes, self-wash dock, and humidifier refill.', role: 'cleaning', aliases: ['Robot Vacuum Cleaner S10', 'Robot Vacuum Cleaner S20'], commands: [ @@ -281,6 +303,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Battery Circulator Fan', category: 'physical', + description: 'Rechargeable table/floor fan with wind modes, speed control, night-light, and auto-off timer.', role: 'fan', aliases: ['Circulator Fan'], commands: [ @@ -295,6 +318,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Blind Tilt', category: 'physical', + description: 'Motorized tilt rod for horizontal blinds; controls slat angle (0=closed, 100=open).', role: 'curtain', commands: [ ...onOff, @@ -308,6 +332,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Roller Shade', category: 'physical', + description: 'Motorized roller blind that moves to a set position (0=open, 100=closed).', role: 'curtain', commands: [ ...onOff, @@ -318,16 +343,18 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Garage Door Opener', category: 'physical', + 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 }, - { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, destructive: true }, + { 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.' }, ], statusFields: ['switchStatus', 'version', 'online'], }, { type: 'Video Doorbell', category: 'physical', + description: 'Wi-Fi video doorbell with motion detection enable/disable control.', role: 'security', commands: [ { command: 'enableMotionDetection', parameter: '—', description: 'Enable motion detection', idempotent: true }, @@ -338,17 +365,19 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Keypad', category: 'physical', + description: 'PIN-pad access controller that creates and deletes door passcodes for a Smart Lock.', 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 }, - { command: 'deleteKey', parameter: '\'{"id":}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, destructive: true }, + { 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.' }, ], statusFields: ['version'], }, { type: 'Candle Warmer Lamp', category: 'physical', + description: 'Decorative candle-warmer lamp with adjustable brightness and color temperature.', role: 'lighting', commands: [ ...onOffToggle, @@ -361,6 +390,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Meter', category: 'physical', + description: 'Battery-powered temperature and humidity sensor; read-only, no control commands.', role: 'sensor', readOnly: true, aliases: ['Meter Plus', 'MeterPro', 'MeterPro(CO2)', 'WoIOSensor', 'Hub 2'], @@ -370,6 +400,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Motion Sensor', category: 'physical', + description: 'PIR motion detector that reports movement and ambient brightness; read-only.', role: 'sensor', readOnly: true, commands: [], @@ -378,6 +409,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Contact Sensor', category: 'physical', + description: 'Door or window open/close sensor that also reports movement and brightness; read-only.', role: 'sensor', readOnly: true, commands: [], @@ -386,6 +418,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Water Leak Detector', category: 'physical', + description: 'Water sensor that reports leak status; read-only, no control commands.', role: 'sensor', readOnly: true, commands: [], @@ -395,6 +428,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Hub Mini', category: 'physical', + description: 'IR hub that bridges BLE devices to the cloud and learns IR remotes; no direct control commands.', role: 'hub', readOnly: true, aliases: ['Hub Mini2'], @@ -404,6 +438,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Hub 3', category: 'physical', + description: 'Wi-Fi hub with built-in temperature, humidity, and light sensors; manages local BLE devices.', role: 'hub', readOnly: true, commands: [], @@ -412,6 +447,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'AI Hub', category: 'physical', + description: 'Advanced hub with AI-based automations; bridges BLE devices to the cloud; read-only status.', role: 'hub', readOnly: true, commands: [], @@ -420,6 +456,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Home Climate Panel', category: 'physical', + description: 'Wall-mounted display showing temperature and humidity; sensor-only, no control.', role: 'climate', readOnly: true, commands: [], @@ -428,6 +465,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Wallet Finder Card', category: 'physical', + description: 'Slim Bluetooth tracker card for locating wallets; reports battery status only.', role: 'sensor', readOnly: true, commands: [], @@ -436,6 +474,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Outdoor Spotlight Cam', category: 'physical', + description: 'Battery-powered outdoor security camera with spotlight; status-only via cloud API.', role: 'security', readOnly: true, commands: [], @@ -446,6 +485,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Air Conditioner', category: 'ir', + description: 'IR-controlled air conditioner with on/off and full HVAC parameter control (mode, fan, temp).', role: 'climate', commands: [ ...onOff, @@ -455,6 +495,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'TV', category: 'ir', + description: 'IR-controlled television or streaming device with channel, volume, and power commands.', role: 'media', aliases: ['IPTV', 'Streamer', 'Set Top Box'], commands: [ @@ -469,6 +510,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'DVD', category: 'ir', + description: 'IR-controlled disc player or speaker with playback, track navigation, and volume commands.', role: 'media', aliases: ['Speaker'], commands: [ @@ -488,6 +530,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Fan', category: 'ir', + description: 'IR-controlled fan with on/off, swing, timer, and speed preset commands.', role: 'fan', commands: [ ...onOff, @@ -501,6 +544,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Light', category: 'ir', + description: 'IR-controlled light fixture with on/off and relative brightness adjustment commands.', role: 'lighting', commands: [ ...onOff, @@ -511,6 +555,7 @@ export const DEVICE_CATALOG: DeviceCatalogEntry[] = [ { type: 'Others', category: 'ir', + description: 'Catch-all for custom IR remotes with user-defined button names learned by a Hub.', role: 'other', commands: [ { command: '', parameter: '—', description: 'User-defined custom IR button (requires --type customize)', commandType: 'customize' }, diff --git a/src/index.ts b/src/index.ts index b816323..bbb3b40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import { registerDoctorCommand } from './commands/doctor.js'; import { registerSchemaCommand } from './commands/schema.js'; import { registerHistoryCommand } from './commands/history.js'; import { registerPlanCommand } from './commands/plan.js'; +import { registerCapabilitiesCommand } from './commands/capabilities.js'; const program = new Command(); @@ -53,6 +54,7 @@ registerDoctorCommand(program); registerSchemaCommand(program); registerHistoryCommand(program); registerPlanCommand(program); +registerCapabilitiesCommand(program); program.addHelpText('after', ` Credentials: diff --git a/src/lib/devices.ts b/src/lib/devices.ts index 9b5b15f..1ae8e97 100644 --- a/src/lib/devices.ts +++ b/src/lib/devices.ts @@ -59,7 +59,7 @@ export interface DescribeResult { controlType: string | null; catalog: DeviceCatalogEntry | null; capabilities: DescribeCapabilities | { liveStatus: Record } | null; - source: 'catalog' | 'live' | 'catalog+live'; + source: 'catalog' | 'live' | 'catalog+live' | 'none'; suggestedActions: ReturnType; /** For IR remotes: the family/room inherited from their bound Hub. Undefined for physical devices. */ inheritedLocation?: { family?: string; room?: string; roomID?: string }; @@ -87,9 +87,7 @@ export class CommandValidationError extends Error { export async function fetchDeviceList(client?: AxiosInstance): Promise { // TTL-gated read: when the on-disk cache is younger than the configured // list TTL, skip the API call and synthesize a DeviceListBody from the - // metadata cache. Only deviceId/deviceName/type/category survive the - // round-trip — other fields (familyName, roomID, hubDeviceId, etc.) are - // not cached. Callers that need those fields should pass --no-cache. + // metadata cache. const mode = getCacheMode(); if (mode.listTtlMs > 0 && isListCacheFresh(mode.listTtlMs)) { const cached = loadCache(); @@ -102,15 +100,20 @@ export async function fetchDeviceList(client?: AxiosInstance): Promise c.command === cmd); + return spec?.destructiveReason ?? null; +} + /** * Describe a device by id: metadata + catalog entry (if known) + * optional live status. Throws `DeviceNotFoundError` when the id is unknown. @@ -294,13 +311,13 @@ export async function describeDevice( } } - const source: 'catalog' | 'live' | 'catalog+live' = catalogEntry + const source: 'catalog' | 'live' | 'catalog+live' | 'none' = catalogEntry ? liveStatus ? 'catalog+live' : 'catalog' : liveStatus ? 'live' - : 'catalog'; + : 'none'; const capabilities: DescribeResult['capabilities'] = catalogEntry ? { @@ -363,3 +380,80 @@ export function searchCatalog(query: string, limit = 20): DeviceCatalogEntry[] { } return hits; } + +/** Shape expected by the MCP describe_device outputSchema. */ +export interface McpDescribeShape { + device: { + deviceId: string; + deviceName: string; + deviceType?: string; + enableCloudService?: boolean; + hubDeviceId?: string; + roomID?: string; + roomName?: string | null; + familyName?: string; + controlType?: string; + remoteType?: string; + }; + isPhysical: boolean; + typeName: string; + controlType: string | null; + source: 'catalog' | 'live' | 'catalog+live' | 'none'; + capabilities: unknown; + suggestedActions: Array<{ command: string; parameter?: string; description: string }>; + inheritedLocation?: { family?: string; room?: string }; +} + +/** Convert a DescribeResult to the shape validated by the MCP outputSchema. */ +export function toMcpDescribeShape(r: DescribeResult): McpDescribeShape { + const d = r.device as Device & Partial; + return { + device: { + deviceId: d.deviceId, + deviceName: d.deviceName, + ...(d.deviceType !== undefined ? { deviceType: d.deviceType } : {}), + ...('enableCloudService' in d ? { enableCloudService: d.enableCloudService } : {}), + ...(d.hubDeviceId !== undefined ? { hubDeviceId: d.hubDeviceId } : {}), + ...(d.roomID !== undefined ? { roomID: d.roomID } : {}), + ...(d.roomName !== undefined ? { roomName: d.roomName } : {}), + ...(d.familyName !== undefined ? { familyName: d.familyName } : {}), + ...(d.controlType !== undefined ? { controlType: d.controlType } : {}), + ...('remoteType' in d && d.remoteType !== undefined ? { remoteType: d.remoteType } : {}), + }, + isPhysical: r.isPhysical, + typeName: r.typeName, + controlType: r.controlType, + source: r.source, + capabilities: r.capabilities, + suggestedActions: r.suggestedActions, + ...(r.inheritedLocation !== undefined + ? { inheritedLocation: { family: r.inheritedLocation.family, room: r.inheritedLocation.room } } + : {}), + }; +} + +/** Shapes the device list body for the MCP list_devices outputSchema. */ +export function toMcpDeviceListShape(d: Device): Record { + return { + deviceId: d.deviceId, + deviceName: d.deviceName, + deviceType: d.deviceType, + enableCloudService: d.enableCloudService, + hubDeviceId: d.hubDeviceId, + roomID: d.roomID, + roomName: d.roomName, + familyName: d.familyName, + controlType: d.controlType, + }; +} + +/** Shapes an infrared remote for the MCP list_devices outputSchema. */ +export function toMcpIrDeviceShape(d: InfraredDevice): Record { + return { + deviceId: d.deviceId, + deviceName: d.deviceName, + remoteType: d.remoteType, + hubDeviceId: d.hubDeviceId, + controlType: d.controlType, + }; +} diff --git a/src/utils/flags.ts b/src/utils/flags.ts index a892cd4..a0956ae 100644 --- a/src/utils/flags.ts +++ b/src/utils/flags.ts @@ -135,6 +135,20 @@ export function getCacheMode(): CacheMode { if (process.argv.includes('--no-cache')) { return { listTtlMs: 0, statusTtlMs: 0 }; } + + // Individual TTL overrides take precedence over the combined --cache flag. + const listFlag = getFlagValue('--cache-list'); + const statusFlag = getFlagValue('--cache-status'); + if (listFlag !== undefined || statusFlag !== undefined) { + const listTtlMs = listFlag !== undefined + ? (parseDurationToMs(listFlag) ?? DEFAULT_LIST_TTL_MS) + : DEFAULT_LIST_TTL_MS; + const statusTtlMs = statusFlag !== undefined + ? (parseDurationToMs(statusFlag) ?? 0) + : 0; + return { listTtlMs, statusTtlMs }; + } + const v = getFlagValue('--cache'); if (!v || v === 'auto') { return { listTtlMs: DEFAULT_LIST_TTL_MS, statusTtlMs: 0 }; diff --git a/src/utils/format.ts b/src/utils/format.ts index 8f9a669..4790b26 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,5 +1,6 @@ -import { printTable, printJson, isJsonMode } from './output.js'; +import { printTable, printJson, isJsonMode, UsageError } from './output.js'; import { getFormat, getFields } from './flags.js'; +import { dump as yamlDump } from 'js-yaml'; export type OutputFormat = 'table' | 'json' | 'jsonl' | 'tsv' | 'yaml' | 'id'; @@ -13,9 +14,15 @@ export function parseFormat(flag: string | undefined): OutputFormat { case 'tsv': return 'tsv'; case 'yaml': return 'yaml'; case 'id': return 'id'; - default: - console.error(`Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`); + default: { + const msg = `Unknown --format "${flag}". Expected: table, json, jsonl, tsv, yaml, id.`; + if (isJsonMode()) { + console.error(JSON.stringify({ error: { code: 2, kind: 'usage', message: msg } })); + } else { + console.error(msg); + } process.exit(2); + } } } @@ -34,10 +41,14 @@ export function filterFields( fields: string[] | undefined, ): { headers: string[]; rows: unknown[][] } { if (!fields || fields.length === 0) return { headers, rows }; - const indices = fields - .map((f) => headers.indexOf(f)) - .filter((i) => i !== -1); - if (indices.length === 0) return { headers, rows }; + const unknown = fields.filter((f) => !headers.includes(f)); + if (unknown.length > 0) { + throw new UsageError( + `Unknown field(s): ${unknown.map((f) => `"${f}"`).join(', ')}. ` + + `Allowed: ${headers.map((f) => `"${f}"`).join(', ')}.`, + ); + } + const indices = fields.map((f) => headers.indexOf(f)); return { headers: indices.map((i) => headers[i]), rows: rows.map((row) => indices.map((i) => row[i])), @@ -47,6 +58,7 @@ export function filterFields( function cellToString(cell: unknown): string { if (cell === null || cell === undefined) return ''; if (typeof cell === 'boolean') return cell ? 'true' : 'false'; + if (typeof cell === 'object') return JSON.stringify(cell); return String(cell); } @@ -94,24 +106,20 @@ export function renderRows( for (const row of r) { const obj = rowToObject(h, row); console.log('---'); - for (const [k, v] of Object.entries(obj)) { - if (v === null || v === undefined) { - console.log(`${k}: ~`); - } else if (typeof v === 'boolean') { - console.log(`${k}: ${v}`); - } else if (typeof v === 'number') { - console.log(`${k}: ${v}`); - } else { - console.log(`${k}: "${String(v).replace(/"/g, '\\"')}"`); - } - } + console.log(yamlDump(obj, { lineWidth: -1 }).trimEnd()); } break; case 'id': { const idIdx = h.indexOf('deviceId') !== -1 ? h.indexOf('deviceId') : h.indexOf('sceneId') !== -1 ? h.indexOf('sceneId') - : 0; + : -1; + if (idIdx === -1) { + throw new UsageError( + `--format=id requires a "deviceId" or "sceneId" column. ` + + `This command outputs: ${h.map((c) => `"${c}"`).join(', ')}.`, + ); + } for (const row of r) { console.log(cellToString(row[idIdx])); } diff --git a/src/utils/output.ts b/src/utils/output.ts index 80516e1..09a5f53 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -45,16 +45,58 @@ export function printKeyValue(data: Record): void { console.log(table.toString()); } +export class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = 'UsageError'; + } +} + +export interface ErrorPayload { + code: number; + kind: 'usage' | 'api' | 'runtime'; + message: string; + hint?: string; + retryable?: boolean; +} + +export function buildErrorPayload(error: unknown): ErrorPayload { + if (error instanceof UsageError) { + return { code: 2, kind: 'usage', message: error.message }; + } + const code = error instanceof ApiError ? error.code : 1; + const kind: ErrorPayload['kind'] = error instanceof ApiError ? 'api' : 'runtime'; + const message = error instanceof Error ? error.message : 'An unknown error occurred'; + const hint = error instanceof ApiError ? (error.hint ?? errorHint(error.code)) : null; + const retryable = error instanceof ApiError ? error.retryable : false; + const payload: ErrorPayload = { code, kind, message }; + if (hint) payload.hint = hint; + if (retryable) payload.retryable = true; + return payload; +} + export function handleError(error: unknown): never { if (error instanceof DryRunSignal) { process.exit(0); } + + const payload = buildErrorPayload(error); + + if (isJsonMode()) { + console.error(JSON.stringify({ error: payload })); + process.exit(payload.code === 2 ? 2 : 1); + } + + if (payload.kind === 'usage') { + console.error(payload.message); + process.exit(2); + } + if (error instanceof ApiError) { - console.error(chalk.red(`Error (code ${error.code}): ${error.message}`)); - const hint = errorHint(error.code); - if (hint) console.error(chalk.grey(`Hint: ${hint}`)); + console.error(chalk.red(`Error (code ${error.code}): ${payload.message}`)); + if (payload.hint) console.error(chalk.grey(`Hint: ${payload.hint}`)); } else if (error instanceof Error) { - console.error(chalk.red(`Error: ${error.message}`)); + console.error(chalk.red(`Error: ${payload.message}`)); } else { console.error(chalk.red('An unknown error occurred')); } diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 3293bf7..db5f580 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -321,7 +321,7 @@ describe('createClient — configurable globals', () => { } as never) ).toThrow(DryRunSignal); - const out = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + const out = writeSpy.mock.calls.map((c) => String(c[0])).join('\n'); expect(out).toContain('[dry-run]'); expect(out).toContain('POST'); expect(out).toContain('/v1.1/devices/ABC/commands'); diff --git a/tests/commands/cache.test.ts b/tests/commands/cache.test.ts index 522bbdb..752a5b3 100644 --- a/tests/commands/cache.test.ts +++ b/tests/commands/cache.test.ts @@ -7,6 +7,7 @@ import { registerCacheCommand } from '../../src/commands/cache.js'; import { updateCacheFromDeviceList, setCachedStatus, + resetListCache, } from '../../src/devices/cache.js'; import { runCli } from '../helpers/cli.js'; @@ -15,10 +16,12 @@ let tmpHome: string; beforeEach(() => { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-cachecmd-')); vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + resetListCache(); }); afterEach(() => { vi.restoreAllMocks(); + resetListCache(); try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { @@ -59,13 +62,14 @@ describe('cache show', () => { }); it('reports status cache entry count + oldest/newest', async () => { - setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-01T00:00:00Z')); + // Both timestamps must be within 24 h of the newer `now` so GC doesn't evict the older entry. + setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-17T10:00:00Z')); setCachedStatus('BOT2', { power: 'off' }, new Date('2026-04-17T12:00:00Z')); const result = await runCli(registerCacheCommand, ['cache', 'show']); expect(result.exitCode).toBeNull(); const out = result.stdout.join('\n'); expect(out).toMatch(/Entries:\s+2/); - expect(out).toMatch(/Oldest:\s+2026-04-01T00:00:00\.000Z/); + expect(out).toMatch(/Oldest:\s+2026-04-17T10:00:00\.000Z/); expect(out).toMatch(/Newest:\s+2026-04-17T12:00:00\.000Z/); }); diff --git a/tests/commands/capabilities.test.ts b/tests/commands/capabilities.test.ts new file mode 100644 index 0000000..eb8d2f1 --- /dev/null +++ b/tests/commands/capabilities.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Command } from 'commander'; +import { registerCapabilitiesCommand } from '../../src/commands/capabilities.js'; + +/** Build a representative program that mirrors the real CLI structure. */ +function makeProgram(): Command { + const p = new Command(); + p.name('switchbot').version('2.1.0'); + p.option('--json', 'Output raw JSON response'); + p.option('--format ', 'Output format'); + p.option('--fields ', 'Column filter'); + p.option('--dry-run', 'Print mutating requests without sending them'); + p.option('--verbose', 'Log HTTP details'); + + const devices = p.command('devices').description('Control and query devices'); + devices.command('list').description('List all devices'); + devices.command('status').description('Get device status'); + devices.command('command').description('Send a command'); + const describe = devices.command('describe').description('Show full device info'); + describe.argument('', 'Device ID'); + describe.option('--json', 'JSON output'); + + p.command('scenes').description('List and run scenes'); + p.command('schema').description('Export device catalog'); + p.command('mcp').description('Start MCP server'); + p.command('plan').description('Execute batch plans'); + + return p; +} + +async function runCapabilities(): Promise> { + const program = makeProgram(); + program.exitOverride(); + + const chunks: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + chunks.push(args.map(String).join(' ')); + }); + + registerCapabilitiesCommand(program); + + try { + await program.parseAsync(['node', 'test', 'capabilities']); + } finally { + logSpy.mockRestore(); + } + + return JSON.parse(chunks.join('')) as Record; +} + +describe('capabilities', () => { + it('outputs valid JSON with all top-level keys', async () => { + const out = await runCapabilities(); + expect(out).toHaveProperty('version'); + expect(out).toHaveProperty('generatedAt'); + expect(out).toHaveProperty('identity'); + expect(out).toHaveProperty('surfaces'); + expect(out).toHaveProperty('commands'); + expect(out).toHaveProperty('globalFlags'); + expect(out).toHaveProperty('catalog'); + }); + + it('identity.product is SwitchBot and quota is 10000', async () => { + const out = await runCapabilities(); + const id = out.identity as Record; + expect(id.product).toBe('SwitchBot'); + expect((id.constraints as Record).quotaPerDay).toBe(10000); + }); + + it('commands list includes known top-level commands', async () => { + const out = await runCapabilities(); + const names = (out.commands as Array<{ name: string }>).map((c) => c.name); + expect(names).toContain('devices'); + expect(names).toContain('scenes'); + expect(names).toContain('schema'); + }); + + it('devices command entry has non-empty subcommands including "list"', async () => { + const out = await runCapabilities(); + const devices = (out.commands as Array<{ name: string; subcommands: Array<{ name: string }> }>).find( + (c) => c.name === 'devices', + ); + expect(devices).toBeDefined(); + expect(Array.isArray(devices!.subcommands)).toBe(true); + expect(devices!.subcommands.length).toBeGreaterThan(0); + const subNames = devices!.subcommands.map((s) => s.name); + expect(subNames).toContain('list'); + }); + + it('devices describe subcommand has args and flags', async () => { + const out = await runCapabilities(); + type Sub = { name: string; args: Array<{ name: string; required: boolean }>; flags: Array<{ flags: string }> }; + const devices = (out.commands as Array<{ name: string; subcommands: Sub[] }>).find( + (c) => c.name === 'devices', + ); + const describe = devices!.subcommands.find((s) => s.name === 'describe'); + expect(describe).toBeDefined(); + expect(describe!.args.length).toBeGreaterThan(0); + expect(describe!.args[0].name).toBeTruthy(); + expect(typeof describe!.args[0].required).toBe('boolean'); + expect(Array.isArray(describe!.flags)).toBe(true); + }); + + it('globalFlags includes --json and --dry-run', async () => { + const out = await runCapabilities(); + const flags = (out.globalFlags as Array<{ flags: string }>).map((f) => f.flags); + expect(flags.some((f) => f.includes('--json'))).toBe(true); + expect(flags.some((f) => f.includes('--dry-run'))).toBe(true); + }); + + it('catalog.roles includes lighting and security, typeCount > 10', async () => { + const out = await runCapabilities(); + const cat = out.catalog as Record; + expect((cat.roles as string[])).toContain('lighting'); + expect((cat.roles as string[])).toContain('security'); + expect(cat.typeCount as number).toBeGreaterThan(10); + }); + + it('surfaces.mcp.tools has 7 entries including send_command', async () => { + const out = await runCapabilities(); + const tools = (out.surfaces as Record).mcp.tools; + expect(tools).toHaveLength(7); + expect(tools).toContain('send_command'); + }); + + it('version matches semver format', async () => { + const out = await runCapabilities(); + expect(out.version as string).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/tests/commands/devices.test.ts b/tests/commands/devices.test.ts index 808c7a5..68c31f9 100644 --- a/tests/commands/devices.test.ts +++ b/tests/commands/devices.test.ts @@ -36,7 +36,7 @@ vi.mock('../../src/api/client.js', () => ({ import { registerDevicesCommand } from '../../src/commands/devices.js'; import { runCli } from '../helpers/cli.js'; -import { updateCacheFromDeviceList } from '../../src/devices/cache.js'; +import { updateCacheFromDeviceList, resetListCache } from '../../src/devices/cache.js'; // ---- Helpers ----------------------------------------------------------- const DID = 'DEV-ID'; @@ -108,6 +108,7 @@ describe('devices command', () => { // real ~/.switchbot/devices.json that might exist on the dev machine. tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-devtest-')); vi.spyOn(os, 'homedir').mockReturnValue(tmpHome); + resetListCache(); apiMock.__instance.get.mockReset(); apiMock.__instance.post.mockReset(); apiMock.createClient.mockReset(); @@ -117,6 +118,7 @@ describe('devices command', () => { afterEach(() => { vi.restoreAllMocks(); + resetListCache(); try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { @@ -135,15 +137,17 @@ describe('devices command', () => { expect(apiMock.__instance.get).toHaveBeenCalledWith('/v1.1/devices'); expect(out).toContain('ABC123'); expect(out).toContain('Living Lamp'); - expect(out).toContain('[IR] TV'); - expect(out).toContain('—'); + // IR type shown without [IR] prefix; category column shows 'ir' + expect(out).toContain('TV'); + expect(out).toContain('ir'); + expect(out).not.toContain('[IR]'); expect(out).toContain('3 physical device'); expect(out).toContain('1 IR remote'); }); - it('shows family and room columns for physical devices', async () => { + it('shows family and room columns with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const out = res.stdout.join('\n'); expect(out).toContain('family'); expect(out).toContain('room'); @@ -151,9 +155,9 @@ describe('devices command', () => { expect(out).toContain('Living Room'); }); - it('renders controlType column for physical and IR devices', async () => { + it('renders controlType column for physical and IR devices with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const out = res.stdout.join('\n'); expect(out).toContain('controlType'); // physical row: 'Light' from controlType @@ -164,7 +168,7 @@ describe('devices command', () => { expect(tvRow).toContain('TV'); }); - it('renders missing controlType as em-dash', async () => { + it('renders missing controlType as em-dash with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: { @@ -181,12 +185,12 @@ describe('devices command', () => { }, }, }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const row = res.stdout.join('\n').split('\n').find((l) => l.includes('NO-CTYPE')); expect(row).toContain('—'); }); - it('renders empty-string controlType and missing deviceType as em-dash', async () => { + it('renders empty-string controlType and missing deviceType as em-dash with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: { @@ -206,24 +210,24 @@ describe('devices command', () => { }, }, }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const row = res.stdout.join('\n').split('\n').find((l) => l.includes('AI-DEV')); expect(row).toBeDefined(); expect(row).not.toContain('undefined'); - // type column (position 3) and controlType column (position 4) should both render as em-dash + // type column (position 3) and controlType column (position 5) should both render as em-dash expect(row!.match(/—/g)?.length).toBeGreaterThanOrEqual(2); }); - it('renders roomID column for physical devices', async () => { + it('renders roomID column for physical devices with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: sampleBody } }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const out = res.stdout.join('\n'); expect(out).toContain('roomID'); const lampRow = out.split('\n').find((l) => l.includes('ABC123')); expect(lampRow).toContain('R-LIVING'); }); - it('renders null roomName as em-dash', async () => { + it('renders null roomName as em-dash with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: { @@ -242,11 +246,11 @@ describe('devices command', () => { }, }, }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); expect(res.stdout.join('\n')).toContain('—'); }); - it('renders empty-string hubDeviceId as em-dash', async () => { + it('renders empty-string hubDeviceId as em-dash with --wide', async () => { apiMock.__instance.get.mockResolvedValue({ data: { body: { @@ -265,7 +269,7 @@ describe('devices command', () => { }, }, }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const out = res.stdout.join('\n'); expect(out).toContain('EMPTY-HUB'); expect(out).toContain('—'); @@ -328,7 +332,7 @@ describe('devices command', () => { }, }, }); - const res = await runCli(registerDevicesCommand, ['devices', 'list']); + const res = await runCli(registerDevicesCommand, ['devices', 'list', '--wide']); const out = res.stdout.join('\n'); // Row for IR-TV should show HomeA / R-HUB-ROOM / Living Room inherited from HUB-MAIN const irTvRow = out.split('\n').find((l) => l.includes('IR-TV')); @@ -386,8 +390,8 @@ describe('devices command', () => { const res = await runCli(registerDevicesCommand, ['devices', 'list', '--format', 'yaml']); const out = res.stdout.join('\n'); expect(out).toContain('---'); - expect(out).toContain('deviceId: "ABC123"'); - expect(out).toContain('deviceName: "Living Lamp"'); + expect(out).toContain('deviceId: ABC123'); + expect(out).toContain('deviceName: Living Lamp'); }); it('--format=table still shows the footer summary', async () => { @@ -437,6 +441,95 @@ describe('devices command', () => { expect(res.exitCode).toBe(1); expect(res.stderr.join('\n')).toContain('device offline'); }); + + it('supports --format=tsv', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: 87, temperature: 22 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'ABC', '--format', 'tsv', + ]); + const out = res.stdout.join('\n'); + expect(out).toContain('power\tbattery\ttemperature'); + expect(out).toContain('on\t87\t22'); + }); + + it('exits 2 for --format id (status has no deviceId column)', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: 87 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'DEV123', '--format', 'id', + ]); + expect(res.exitCode).toBe(2); + }); + + it('supports --format json (array of objects)', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'off', battery: 50 } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'ABC', '--format', 'json', + ]); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toEqual({ power: 'off', battery: 50 }); + }); + + it('serializes nested objects to JSON strings in tsv output', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { + body: { + power: 'on', + calibration: { min: 0, max: 100 }, + tags: ['living', 'main'], + battery: 90, + }, + }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'DEV1', '--format', 'tsv', + ]); + const lines = res.stdout.join('\n').split('\n'); + // Headers + expect(lines[0]).toContain('calibration'); + expect(lines[0]).toContain('tags'); + // Values: nested object and array are JSON-stringified + expect(lines[1]).toContain('{"min":0,"max":100}'); + expect(lines[1]).toContain('["living","main"]'); + }); + + it('preserves nested objects as real values in --format json', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { + body: { + power: 'on', + motion: { x: 1, y: 2 }, + modes: ['eco', 'turbo'], + }, + }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'DEV2', '--format', 'json', + ]); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed[0].power).toBe('on'); + // Nested object/array fields come through as real JS values. + expect(parsed[0].motion).toEqual({ x: 1, y: 2 }); + expect(parsed[0].modes).toEqual(['eco', 'turbo']); + }); + + it('null status fields appear as empty string in tsv', async () => { + apiMock.__instance.get.mockResolvedValue({ + data: { body: { power: 'on', battery: null } }, + }); + const res = await runCli(registerDevicesCommand, [ + 'devices', 'status', 'DEV3', '--format', 'tsv', + ]); + const lines = res.stdout.join('\n').split('\n'); + // null maps to empty string in cellToString + expect(lines[1]).toBe('on\t'); + }); }); // ===================================================================== @@ -1100,11 +1193,9 @@ describe('devices command', () => { expect(lines.find((l) => l.startsWith('Bot\t'))).toBeDefined(); }); - it('--format=id outputs one type per line', async () => { + it('--format=id exits 2 (types has no deviceId column)', async () => { const res = await runCli(registerDevicesCommand, ['devices', 'types', '--format', 'id']); - const lines = res.stdout.join('\n').split('\n').filter(Boolean); - expect(lines).toContain('Bot'); - expect(lines).toContain('Curtain'); + expect(res.exitCode).toBe(2); }); }); @@ -1451,6 +1542,30 @@ describe('devices command', () => { expect(parsed.capabilities.liveStatus).toHaveProperty('error', 'device offline'); }); + it('returns source=none when device type is unknown and --live not set', async () => { + const unknownTypeBody = { + deviceList: [{ + deviceId: 'UNKNOWN-1', + deviceName: 'Future Device', + deviceType: 'UnknownDeviceType2025', + hubDeviceId: 'HUB-1', + enableCloudService: true, + }], + infraredRemoteList: [], + }; + apiMock.__instance.get.mockResolvedValueOnce({ data: { body: unknownTypeBody } }); + const res = await runCli(registerDevicesCommand, [ + 'devices', + 'describe', + 'UNKNOWN-1', + '--json', + ]); + expect(res.exitCode).toBeNull(); + const parsed = JSON.parse(res.stdout.join('\n')); + expect(parsed.source).toBe('none'); + expect(parsed.capabilities).toBeNull(); + }); + it('propagates API errors via handleError (exit 1)', async () => { apiMock.__instance.get.mockRejectedValue(new Error('boom')); const res = await runCli(registerDevicesCommand, ['devices', 'describe', 'BLE-001']); @@ -1641,11 +1756,12 @@ describe('devices command', () => { '--json', 'devices', 'command', LOCK_ID, 'unlock', ]); expect(res.exitCode).toBe(2); - const parsed = JSON.parse(res.stdout.join('\n')); - expect(parsed.error.code).toBe('destructive_requires_confirm'); - expect(parsed.error.deviceId).toBe(LOCK_ID); - expect(parsed.error.command).toBe('unlock'); - expect(parsed.error.deviceType).toBe('Smart Lock'); + const parsed = JSON.parse(res.stderr.join('\n')); + expect(parsed.error.kind).toBe('guard'); + expect(parsed.error.code).toBe(2); + expect(parsed.error.context.deviceId).toBe(LOCK_ID); + expect(parsed.error.context.command).toBe('unlock'); + expect(parsed.error.context.deviceType).toBe('Smart Lock'); }); it('does not guard --type customize (user-defined IR buttons)', async () => { diff --git a/tests/commands/explain.test.ts b/tests/commands/explain.test.ts new file mode 100644 index 0000000..f219cc7 --- /dev/null +++ b/tests/commands/explain.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { runCli } from '../helpers/cli.js'; + +// --------------------------------------------------------------------------- +// Mock the lib/devices layer so no real HTTP calls are made. +// --------------------------------------------------------------------------- +const devicesMock = vi.hoisted(() => { + class DeviceNotFoundError extends Error { + constructor(public readonly deviceId: string) { + super(`No device with id "${deviceId}" found on this account.`); + this.name = 'DeviceNotFoundError'; + } + } + + const describeDevice = vi.fn(); + const fetchDeviceList = vi.fn(); + + return { describeDevice, fetchDeviceList, DeviceNotFoundError }; +}); + +vi.mock('../../src/lib/devices.js', () => ({ + describeDevice: devicesMock.describeDevice, + fetchDeviceList: devicesMock.fetchDeviceList, + DeviceNotFoundError: devicesMock.DeviceNotFoundError, + buildHubLocationMap: vi.fn(() => new Map()), + isDestructiveCommand: vi.fn(() => false), + validateCommand: vi.fn(() => ({ ok: true })), + executeCommand: vi.fn(), + fetchDeviceStatus: vi.fn(), + searchCatalog: vi.fn(() => []), +})); + +vi.mock('../../src/devices/cache.js', () => ({ + getCachedDevice: vi.fn(() => null), + updateCacheFromDeviceList: vi.fn(), + loadCache: vi.fn(() => null), + isListCacheFresh: vi.fn(() => false), + getCachedStatus: vi.fn(() => null), + setCachedStatus: vi.fn(), + clearCache: vi.fn(), + clearStatusCache: vi.fn(), + loadStatusCache: vi.fn(() => ({ entries: {} })), + describeCache: vi.fn(() => ({ + list: { path: '', exists: false }, + status: { path: '', exists: false, entryCount: 0 }, + })), +})); + +import { registerDevicesCommand } from '../../src/commands/devices.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const DID = 'BOT-001'; + +const botDescribeResult = { + device: { + deviceId: DID, + deviceName: 'My Bot', + deviceType: 'Bot', + enableCloudService: true, + hubDeviceId: 'HUB-001', + familyName: 'Home', + roomName: 'Living Room', + }, + isPhysical: true, + typeName: 'Bot', + controlType: 'command' as const, + catalog: { + type: 'Bot', + category: 'physical' as const, + commands: [ + { command: 'turnOn', parameter: '—', description: 'Turn on', idempotent: true, destructive: false }, + { command: 'turnOff', parameter: '—', description: 'Turn off', idempotent: true, destructive: false }, + ], + statusFields: ['power', 'battery'], + role: 'power' as const, + readOnly: false, + }, + capabilities: { + role: 'power', + readOnly: false, + commands: [ + { command: 'turnOn', parameter: '—', description: 'Turn on', idempotent: true, destructive: false }, + { command: 'turnOff', parameter: '—', description: 'Turn off', idempotent: true, destructive: false }, + ], + statusFields: ['power', 'battery'], + liveStatus: { power: 'on', battery: 95, deviceId: DID }, + }, + source: 'catalog+live' as const, + suggestedActions: [ + { command: 'turnOn', description: 'Turn on' }, + { command: 'turnOff', description: 'Turn off' }, + ], +}; + +async function runExplain(...args: string[]) { + return runCli(registerDevicesCommand, ['devices', 'explain', ...args]); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('devices explain', () => { + beforeEach(() => { + devicesMock.describeDevice.mockReset(); + devicesMock.fetchDeviceList.mockReset(); + devicesMock.fetchDeviceList.mockResolvedValue({ deviceList: [], infraredRemoteList: [] }); + }); + + it('--json: returns correct ExplainResult shape on success', async () => { + devicesMock.describeDevice.mockResolvedValue(botDescribeResult); + + const res = await runExplain('--json', DID); + + expect(res.exitCode).toBeNull(); + const parsed = JSON.parse(res.stdout[0]); + expect(parsed.deviceId).toBe(DID); + expect(parsed.type).toBe('Bot'); + expect(parsed.category).toBe('physical'); + expect(parsed.name).toBe('My Bot'); + expect(parsed.role).toBe('power'); + expect(parsed.readOnly).toBe(false); + expect(Array.isArray(parsed.commands)).toBe(true); + expect(parsed.commands[0].command).toBe('turnOn'); + expect(parsed.commands[0].idempotent).toBe(true); + expect(Array.isArray(parsed.statusFields)).toBe(true); + expect(parsed.liveStatus).toMatchObject({ power: 'on', battery: 95 }); + expect(Array.isArray(parsed.suggestedActions)).toBe(true); + expect(Array.isArray(parsed.warnings)).toBe(true); + expect(parsed.warnings).toHaveLength(0); + }); + + it('--json: device not found emits { error: { code:1, kind:"runtime" } } on stderr', async () => { + devicesMock.describeDevice.mockRejectedValue(new devicesMock.DeviceNotFoundError('MISSING')); + + const res = await runExplain('--json', 'MISSING'); + + expect(res.exitCode).toBe(1); + expect(res.stdout).toHaveLength(0); + const parsed = JSON.parse(res.stderr[0]); + expect(parsed.error.code).toBe(1); + expect(parsed.error.kind).toBe('runtime'); + expect(parsed.error.message).toContain('MISSING'); + }); + + it('human mode: device not found prints plain text to stderr', async () => { + devicesMock.describeDevice.mockRejectedValue(new devicesMock.DeviceNotFoundError('MISSING')); + + const res = await runExplain('MISSING'); + + expect(res.exitCode).toBe(1); + expect(res.stdout).toHaveLength(0); + expect(res.stderr[0]).toContain('MISSING'); + expect(res.stderr[0]).not.toContain('"error"'); + }); + + it('--no-live: calls describeDevice with live:false', async () => { + devicesMock.describeDevice.mockResolvedValue(botDescribeResult); + + await runExplain('--json', '--no-live', DID); + + expect(devicesMock.describeDevice).toHaveBeenCalledWith(DID, { live: false }); + }); + + it('default: calls describeDevice with live:true', async () => { + devicesMock.describeDevice.mockResolvedValue(botDescribeResult); + + await runExplain('--json', DID); + + expect(devicesMock.describeDevice).toHaveBeenCalledWith(DID, { live: true }); + }); + + it('--json: cloud service disabled emits a warning', async () => { + const noCloud = { + ...botDescribeResult, + device: { ...botDescribeResult.device, enableCloudService: false }, + }; + devicesMock.describeDevice.mockResolvedValue(noCloud); + + const res = await runExplain('--json', DID); + + const parsed = JSON.parse(res.stdout[0]); + expect(parsed.warnings.some((w: string) => w.toLowerCase().includes('cloud'))).toBe(true); + }); + + it('--json: hub role fetches and lists IR children', async () => { + const hubResult = { + ...botDescribeResult, + typeName: 'Hub 2', + catalog: { ...botDescribeResult.catalog, role: 'hub' as const, type: 'Hub 2' }, + }; + devicesMock.describeDevice.mockResolvedValue(hubResult); + devicesMock.fetchDeviceList.mockResolvedValue({ + deviceList: [], + infraredRemoteList: [ + { deviceId: 'IR-1', deviceName: 'TV Remote', remoteType: 'TV', hubDeviceId: DID }, + { deviceId: 'IR-2', deviceName: 'AC Remote', remoteType: 'Air Conditioner', hubDeviceId: 'OTHER' }, + ], + }); + + const res = await runExplain('--json', DID); + + const parsed = JSON.parse(res.stdout[0]); + expect(parsed.children).toHaveLength(1); + expect(parsed.children[0].deviceId).toBe('IR-1'); + expect(parsed.children[0].type).toBe('TV'); + }); + + it('human mode: prints device header and commands', async () => { + devicesMock.describeDevice.mockResolvedValue(botDescribeResult); + + const res = await runExplain(DID); + + expect(res.exitCode).toBeNull(); + const output = res.stdout.join('\n'); + expect(output).toContain('My Bot'); + expect(output).toContain(DID); + expect(output).toContain('turnOn'); + }); +}); diff --git a/tests/commands/mcp-http.test.ts b/tests/commands/mcp-http.test.ts new file mode 100644 index 0000000..95f9cf5 --- /dev/null +++ b/tests/commands/mcp-http.test.ts @@ -0,0 +1,223 @@ +/** + * Integration tests for `mcp serve --port` (HTTP transport). + * + * These tests start a real HTTP server on a random port and exercise + * the StreamableHTTP transport directly — covering successful round-trips, + * malformed input, sequential stateless requests, and the res.on('close') + * cleanup path. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { createServer, request as httpRequest } from 'node:http'; +import type { IncomingMessage, ServerResponse, Server } from 'node:http'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +// --------------------------------------------------------------------------- +// Mocks (same setup as mcp.test.ts so createSwitchBotMcpServer doesn't call +// real API endpoints during protocol-level tests). +// --------------------------------------------------------------------------- +const apiMock = vi.hoisted(() => { + const instance = { get: vi.fn(), post: vi.fn() }; + return { createClient: vi.fn(() => instance), __instance: instance }; +}); + +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 { createSwitchBotMcpServer } from '../../src/commands/mcp.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Replicates the exact handler used by `mcp serve --port`. */ +function makeHandler() { + return async (req: IncomingMessage, res: ServerResponse) => { + const reqTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const reqServer = createSwitchBotMcpServer(); + res.on('close', () => { + reqTransport.close(); + reqServer.close(); + }); + try { + await reqServer.connect(reqTransport); + await reqTransport.handleRequest(req, res); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error' }, id: null })); + } + } + }; +} + +function startServer(): Promise<{ server: Server; port: number; stop: () => Promise }> { + return new Promise((resolve, reject) => { + const server = createServer(makeHandler()); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as { port: number }; + resolve({ + server, + port: addr.port, + stop: () => new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), + }); + }); + server.on('error', reject); + }); +} + +/** POST to /mcp and collect the full response body. */ +function post(port: number, bodyStr: string): Promise<{ status: number; contentType: string; body: string }> { + return new Promise((resolve, reject) => { + const req = httpRequest( + { + hostname: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(bodyStr), + Accept: 'application/json, text/event-stream', + }, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => { data += chunk; }); + res.on('end', () => + resolve({ status: res.statusCode ?? 0, contentType: res.headers['content-type'] ?? '', body: data }), + ); + }, + ); + req.on('error', reject); + req.write(bodyStr); + req.end(); + }); +} + +/** + * Parse a JSON-RPC response from either a direct JSON body or an SSE stream. + * Returns null when the body is empty or contains no data frame. + */ +function parseRpc(body: string, contentType: string): unknown { + if (contentType.includes('text/event-stream')) { + const dataLine = body.split('\n').find((l) => l.startsWith('data: ')); + if (!dataLine) return null; + return JSON.parse(dataLine.slice('data: '.length)); + } + return body ? JSON.parse(body) : null; +} + +const INIT_MSG = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '0.1' }, + }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('mcp serve --port (HTTP transport)', () => { + let port: number; + let stop: () => Promise; + + beforeAll(async () => { + const srv = await startServer(); + port = srv.port; + stop = srv.stop; + }); + + afterAll(async () => { + await stop(); + }); + + it('returns 200 and a valid MCP initialize result', async () => { + const resp = await post(port, JSON.stringify(INIT_MSG)); + expect(resp.status).toBe(200); + const rpc = parseRpc(resp.body, resp.contentType) as { jsonrpc: string; id: number; result: { serverInfo: { name: string } } } | null; + expect(rpc).not.toBeNull(); + expect(rpc!.jsonrpc).toBe('2.0'); + expect(rpc!.id).toBe(1); + expect(rpc!.result.serverInfo.name).toBe('switchbot'); + }); + + it('returns an error status (4xx/5xx) for malformed JSON', async () => { + const resp = await post(port, 'NOT VALID JSON {{{'); + expect(resp.status).toBeGreaterThanOrEqual(400); + }); + + it('handles multiple sequential requests independently (stateless)', async () => { + const r1 = await post(port, JSON.stringify({ ...INIT_MSG, id: 10 })); + const r2 = await post(port, JSON.stringify({ ...INIT_MSG, id: 20 })); + + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + + const rpc1 = parseRpc(r1.body, r1.contentType) as { id: number } | null; + const rpc2 = parseRpc(r2.body, r2.contentType) as { id: number } | null; + // Each request gets back its own id — no state bleed from request 1 to 2. + expect(rpc1!.id).toBe(10); + expect(rpc2!.id).toBe(20); + }); + + it('calls transport.close() after connection closes (no resource leak)', async () => { + const closeSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'close'); + + await post(port, JSON.stringify(INIT_MSG)); + // Wait for the res 'close' event to propagate after the HTTP response ends. + await vi.waitFor(() => { + expect(closeSpy).toHaveBeenCalled(); + }, { timeout: 1000 }); + + closeSpy.mockRestore(); + }); + + it('calls transport.close() even when handleRequest throws (error-path cleanup)', async () => { + const closeSpy = vi.spyOn(StreamableHTTPServerTransport.prototype, 'close'); + + // Malformed JSON causes an immediate error response (connection closes after the 4xx). + await post(port, 'NOT VALID JSON {{{'); + + // res.on('close') fires after the response ends — wait for the spy. + await vi.waitFor(() => { + expect(closeSpy).toHaveBeenCalled(); + }, { timeout: 2000 }); + + closeSpy.mockRestore(); + }); +}); diff --git a/tests/commands/mcp.test.ts b/tests/commands/mcp.test.ts index 92c5f36..1e8dca3 100644 --- a/tests/commands/mcp.test.ts +++ b/tests/commands/mcp.test.ts @@ -112,9 +112,13 @@ describe('mcp server', () => { expect(res.isError).toBe(true); const text = (res.content as Array<{ type: string; text: string }>)[0].text; const parsed = JSON.parse(text); - expect(parsed.error).toBe('destructive_requires_confirm'); - expect(parsed.command).toBe('unlock'); - expect(parsed.deviceType).toBe('Smart Lock'); + expect(parsed.error.kind).toBe('guard'); + expect(parsed.error.code).toBe(3); + expect(parsed.error.context.command).toBe('unlock'); + expect(parsed.error.context.deviceType).toBe('Smart Lock'); + // destructiveReason should be present so agents can explain it to users + expect(parsed.error.context.destructiveReason).toBeTypeOf('string'); + expect(parsed.error.hint).toContain('Reason:'); // Should not have called the API at all. expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); @@ -149,8 +153,9 @@ describe('mcp server', () => { expect(res.isError).toBe(true); const parsed = JSON.parse((res.content as Array<{ text: string }>)[0].text); - expect(parsed.error).toBe('validation_failed'); - expect(parsed.kind).toBe('unknown-command'); + expect(parsed.error.kind).toBe('usage'); + expect(parsed.error.code).toBe(2); + expect(parsed.error.context.validationKind).toBe('unknown-command'); expect(apiMock.__instance.post).not.toHaveBeenCalled(); }); diff --git a/tests/commands/schema.test.ts b/tests/commands/schema.test.ts index 2d154f0..c43a4e0 100644 --- a/tests/commands/schema.test.ts +++ b/tests/commands/schema.test.ts @@ -43,4 +43,39 @@ describe('schema export', () => { const unlock = lock.commands.find((c: { command: string }) => c.command === 'unlock'); if (unlock) expect(unlock.destructive).toBe(true); }); + + it('--role filters to the matching functional group', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'lighting']); + const parsed = JSON.parse(res.stdout.join('')); + expect(parsed.types.length).toBeGreaterThan(0); + for (const t of parsed.types) { + expect(t.role).toBe('lighting'); + } + expect(parsed.types.find((t: { type: string }) => t.type === 'Smart Lock')).toBeUndefined(); + }); + + it('--role and --category can be combined', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'security', '--category', 'physical']); + const parsed = JSON.parse(res.stdout.join('')); + expect(parsed.types.length).toBeGreaterThan(0); + for (const t of parsed.types) { + expect(t.role).toBe('security'); + expect(t.category).toBe('physical'); + } + }); + + it('--role returns empty types[] for an unknown role', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export', '--role', 'nonexistent']); + const parsed = JSON.parse(res.stdout.join('')); + expect(parsed.types).toEqual([]); + }); + + it('schema export includes description on every type', async () => { + const res = await runCli(registerSchemaCommand, ['schema', 'export']); + const parsed = JSON.parse(res.stdout.join('')); + for (const t of parsed.types) { + expect(t.description, `${t.type} missing description in export`).toBeTypeOf('string'); + expect((t.description as string).length, `${t.type} description is empty`).toBeGreaterThan(0); + } + }); }); diff --git a/tests/devices/cache.test.ts b/tests/devices/cache.test.ts index b8eb579..804535f 100644 --- a/tests/devices/cache.test.ts +++ b/tests/devices/cache.test.ts @@ -15,6 +15,7 @@ import { setCachedStatus, clearStatusCache, describeCache, + resetListCache, } from '../../src/devices/cache.js'; // Redirect the cache to a test-only temp directory by overriding both @@ -25,10 +26,12 @@ beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sbcli-cache-')); vi.spyOn(os, 'homedir').mockReturnValue(tmpDir); process.argv = ['node', 'switchbot']; + resetListCache(); }); afterEach(() => { vi.restoreAllMocks(); + resetListCache(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -177,6 +180,8 @@ describe('list cache TTL', () => { const raw = JSON.parse(fs.readFileSync(file, 'utf-8')); raw.lastUpdated = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); fs.writeFileSync(file, JSON.stringify(raw)); + // Flush the hot-cache so the next call re-reads the modified file. + resetListCache(); expect(isListCacheFresh(60 * 60 * 1000)).toBe(false); }); }); @@ -262,12 +267,13 @@ describe('describeCache', () => { }); it('reports oldest/newest status timestamps when populated', () => { - setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-01T00:00:00Z')); + // Both timestamps must be within 24 h of each other so GC doesn't evict the older entry. + setCachedStatus('BOT1', { power: 'on' }, new Date('2026-04-17T10:00:00Z')); setCachedStatus('BOT2', { power: 'off' }, new Date('2026-04-17T12:00:00Z')); const s = describeCache(); expect(s.status.exists).toBe(true); expect(s.status.entryCount).toBe(2); - expect(s.status.oldestFetchedAt).toBe('2026-04-01T00:00:00.000Z'); + expect(s.status.oldestFetchedAt).toBe('2026-04-17T10:00:00.000Z'); expect(s.status.newestFetchedAt).toBe('2026-04-17T12:00:00.000Z'); }); }); diff --git a/tests/devices/catalog.test.ts b/tests/devices/catalog.test.ts index 786dedc..b473594 100644 --- a/tests/devices/catalog.test.ts +++ b/tests/devices/catalog.test.ts @@ -40,6 +40,26 @@ describe('devices/catalog', () => { const unique = new Set(types); expect(types.length).toBe(unique.size); }); + + it('every entry has a description string', () => { + for (const entry of DEVICE_CATALOG) { + expect(entry.description, `${entry.type} is missing description`).toBeTypeOf('string'); + expect((entry.description as string).length, `${entry.type} description is empty`).toBeGreaterThan(0); + } + }); + + it('every destructive command has a destructiveReason', () => { + for (const entry of DEVICE_CATALOG) { + for (const cmd of entry.commands) { + if (cmd.destructive) { + expect( + cmd.destructiveReason, + `${entry.type}.${cmd.command} is destructive but missing destructiveReason` + ).toBeTypeOf('string'); + } + } + } + }); }); describe('command annotations', () => { diff --git a/tests/utils/format.test.ts b/tests/utils/format.test.ts index c19b200..d327bde 100644 --- a/tests/utils/format.test.ts +++ b/tests/utils/format.test.ts @@ -73,10 +73,8 @@ describe('filterFields', () => { expect(result.rows).toEqual([['A1', 'Bot'], ['A2', 'Smart Lock']]); }); - it('ignores unknown field names', () => { - const result = filterFields(headers, rows, ['id', 'nonexistent']); - expect(result.headers).toEqual(['id']); - expect(result.rows).toEqual([['A1'], ['A2']]); + it('exits 2 on unknown field names', () => { + expect(() => filterFields(headers, rows, ['id', 'nonexistent'])).toThrow('Unknown field(s): "nonexistent"'); }); it('preserves requested field order', () => { @@ -141,9 +139,9 @@ describe('renderRows', () => { renderRows(headers, rows, 'yaml'); const combined = logOutput.join('\n'); expect(combined).toContain('---'); - expect(combined).toContain('deviceId: "DEV1"'); - expect(combined).toContain('name: "Light"'); - expect(combined).toContain('type: "Smart Lock"'); + expect(combined).toContain('deviceId: DEV1'); + expect(combined).toContain('name: Light'); + expect(combined).toContain('type: Smart Lock'); }); it('id: outputs the first column (deviceId) by default', () => { @@ -156,6 +154,10 @@ describe('renderRows', () => { expect(logOutput).toEqual(['S1', 'S2']); }); + it('id: exits 2 when no deviceId or sceneId column exists', () => { + expect(() => renderRows(['power', 'battery'], [['on', 87]], 'id')).toThrow('--format=id requires'); + }); + it('handles null/undefined/boolean cells in tsv', () => { renderRows(['a', 'b', 'c'], [[null, undefined, true]], 'tsv'); expect(logOutput[1]).toBe('\t\ttrue'); @@ -164,8 +166,8 @@ describe('renderRows', () => { it('handles null cells in yaml', () => { renderRows(['a', 'b'], [[null, 'ok']], 'yaml'); const combined = logOutput.join('\n'); - expect(combined).toContain('a: ~'); - expect(combined).toContain('b: "ok"'); + expect(combined).toContain('a: null'); + expect(combined).toContain('b: ok'); }); }); diff --git a/tests/utils/output.test.ts b/tests/utils/output.test.ts index 9b5c4c1..55d6056 100644 --- a/tests/utils/output.test.ts +++ b/tests/utils/output.test.ts @@ -5,6 +5,8 @@ import { printTable, printKeyValue, handleError, + buildErrorPayload, + UsageError, } from '../../src/utils/output.js'; describe('isJsonMode', () => { @@ -205,4 +207,134 @@ describe('handleError', () => { expect(joined).toContain('Error (code 999): x'); expect(joined).not.toContain('Hint:'); }); + + it('prefers ApiError.hint over errorHint fallback in human-readable output', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + + // code 190 has an errorHint; error.hint on the instance should win + expect(() => handleError(new ApiError('bad cmd', 190, { hint: 'use describe instead' }))).toThrow('__exit'); + const joined = errSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(joined).toContain('use describe instead'); + expect(joined).not.toContain('switchbot devices list'); + }); + + describe('--json mode', () => { + let originalArgv: string[]; + beforeEach(() => { + originalArgv = process.argv; + process.argv = ['node', 'cli', '--json', 'devices', 'status', 'X']; + }); + afterEach(() => { + process.argv = originalArgv; + }); + + it('outputs structured JSON error to stderr for ApiError', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + + expect(() => handleError(new ApiError('bad device', 190))).toThrow('__exit'); + const raw = errSpy.mock.calls[0][0]; + const parsed = JSON.parse(raw); + expect(parsed.error.code).toBe(190); + expect(parsed.error.message).toBe('bad device'); + expect(parsed.error.hint).toMatch(/devices/); + }); + + it('marks 429 errors as retryable when ApiError.retryable is true', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + + // Simulate what client.ts creates: retryable: true set explicitly. + expect(() => handleError(new ApiError('rate limited', 429, { retryable: true, hint: 'check quota' }))).toThrow('__exit'); + const parsed = JSON.parse(errSpy.mock.calls[0][0]); + expect(parsed.error.retryable).toBe(true); + expect(parsed.error.hint).toBe('check quota'); + }); + + it('prefers ApiError.hint over errorHint fallback when both exist', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + + // code 429 has an errorHint, but the explicit hint should win. + expect(() => handleError(new ApiError('over limit', 429, { retryable: true, hint: 'custom hint from client' }))).toThrow('__exit'); + const parsed = JSON.parse(errSpy.mock.calls[0][0]); + expect(parsed.error.hint).toBe('custom hint from client'); + }); + + it('does NOT set retryable when ApiError.retryable is false', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + + expect(() => handleError(new ApiError('auth failed', 401, { retryable: false }))).toThrow('__exit'); + const parsed = JSON.parse(errSpy.mock.calls[0][0]); + expect(parsed.error.retryable).toBeUndefined(); + }); + + it('outputs structured JSON error for generic Error', () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('__exit'); + }); + + expect(() => handleError(new Error('kaboom'))).toThrow('__exit'); + const parsed = JSON.parse(errSpy.mock.calls[0][0]); + expect(parsed.error.code).toBe(1); + expect(parsed.error.message).toBe('kaboom'); + }); + }); +}); + +describe('buildErrorPayload', () => { + it('UsageError → code 2, kind usage', () => { + const p = buildErrorPayload(new UsageError('bad flag')); + expect(p).toEqual({ code: 2, kind: 'usage', message: 'bad flag' }); + }); + + it('generic Error → code 1, kind runtime', () => { + const p = buildErrorPayload(new Error('oops')); + expect(p.code).toBe(1); + expect(p.kind).toBe('runtime'); + expect(p.message).toBe('oops'); + expect(p.hint).toBeUndefined(); + expect(p.retryable).toBeUndefined(); + }); + + it('unknown non-Error → code 1, kind runtime, fallback message', () => { + const p = buildErrorPayload('just a string'); + expect(p.code).toBe(1); + expect(p.kind).toBe('runtime'); + expect(p.message).toBe('An unknown error occurred'); + }); + + it('ApiError → code from error, kind api, hint from error', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const p = buildErrorPayload(new ApiError('quota', 429, { retryable: true, hint: 'try later' })); + expect(p.code).toBe(429); + expect(p.kind).toBe('api'); + expect(p.message).toBe('quota'); + expect(p.hint).toBe('try later'); + expect(p.retryable).toBe(true); + }); + + it('ApiError with known code gets hint from errorHint table when no explicit hint', async () => { + const { ApiError } = await import('../../src/api/client.js'); + const p = buildErrorPayload(new ApiError('not found', 152)); + expect(p.hint).toContain('deviceId'); + }); }); From 0ac2790c344d6835131a5f4efd488d5635e0921a Mon Sep 17 00:00:00 2001 From: chenliuyun Date: Sun, 19 Apr 2026 02:07:26 +0800 Subject: [PATCH 26/26] chore: release v1.2.0 --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 3aafe99..d802162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchbot/openapi-cli", - "version": "2.1.0", + "version": "1.2.0", "description": "Command-line interface for SwitchBot API v1.1", "keywords": [ "switchbot", @@ -50,9 +50,11 @@ "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^12.1.0", + "js-yaml": "^4.1.1", "uuid": "^11.0.5" }, "devDependencies": { + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.7", "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^2.1.9",