@switchbot/openapi-cli 2.6.4 → 2.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
- import { printJson, isJsonMode, handleError, UsageError } from '../utils/output.js';
1
+ import { printJson, isJsonMode, handleError, UsageError, emitStreamHeader } from '../utils/output.js';
2
2
  import { fetchDeviceStatus } from '../lib/devices.js';
3
3
  import { getCachedDevice } from '../devices/cache.js';
4
4
  import { parseDurationToMs, getFields } from '../utils/flags.js';
5
5
  import { intArg, durationArg, stringArg } from '../utils/arg-parsers.js';
6
6
  import { createClient } from '../api/client.js';
7
7
  import { resolveDeviceId } from '../utils/name-resolver.js';
8
+ import { resolveFieldList, listAllCanonical } from '../schema/field-aliases.js';
8
9
  const DEFAULT_INTERVAL_MS = 30_000;
9
10
  const MIN_INTERVAL_MS = 1_000;
10
11
  function diff(prev, next, fields) {
@@ -101,7 +102,15 @@ Examples:
101
102
  maxTicks = Math.floor(n);
102
103
  }
103
104
  const forMs = options.for ? parseDurationToMs(options.for) : null;
104
- const fields = getFields() ?? null;
105
+ const rawFields = getFields() ?? null;
106
+ // Resolve aliases upfront against the static canonical registry.
107
+ // Validating here lets UsageError exit the command before any
108
+ // polling starts, and keeps mid-loop error handling free of
109
+ // "misuse" concerns. Unknown fields that are not registered as
110
+ // aliases but happen to match an API key pass through unchanged.
111
+ const fields = rawFields
112
+ ? resolveFieldList(rawFields, listAllCanonical())
113
+ : null;
105
114
  const ac = new AbortController();
106
115
  const onSig = () => ac.abort();
107
116
  process.on('SIGINT', onSig);
@@ -109,6 +118,10 @@ Examples:
109
118
  const forTimer = forMs !== null && forMs > 0
110
119
  ? setTimeout(() => ac.abort(), forMs)
111
120
  : null;
121
+ // P7: streaming JSON contract — first line under --json is the
122
+ // stream header so consumers can route by eventKind/cadence.
123
+ if (isJsonMode())
124
+ emitStreamHeader({ eventKind: 'tick', cadence: 'poll' });
112
125
  try {
113
126
  const prev = new Map();
114
127
  const client = createClient();
@@ -7,14 +7,99 @@
7
7
  * - CommandSpec.idempotent: repeat-safe — calling it N times ends in the
8
8
  * same state as calling it once (turnOn, setBrightness 50). Agents can
9
9
  * retry these freely. Counter-examples: toggle, press, volumeAdd.
10
- * - CommandSpec.destructive: causes a real-world effect that is hard or
11
- * unsafe to reverse (unlock, garage open, deleteKey). UIs and agents
12
- * should require explicit confirmation before issuing these.
10
+ * - CommandSpec.safetyTier: explicit action safety classification. See
11
+ * SafetyTier for the 5-tier enum. Built-in entries set this on the
12
+ * destructive tier; other tiers are derived (see deriveSafetyTier).
13
+ * - CommandSpec.destructive (deprecated, v3.0 removal): legacy boolean
14
+ * that maps to safetyTier === 'destructive'. Still accepted in
15
+ * ~/.switchbot/catalog.json overlays and derived into safetyTier.
13
16
  * - DeviceCatalogEntry.role: functional grouping for filter/search
14
17
  * ("all lighting", "all security"). Does not affect API behavior.
15
18
  * - DeviceCatalogEntry.readOnly: the device has no control commands; it
16
19
  * can only be queried via 'devices status'.
17
20
  */
21
+ /**
22
+ * Catalog shape version. Bump when any of the exported interfaces
23
+ * (CommandSpec / DeviceCatalogEntry / SafetyTier) gain/lose/rename a
24
+ * load-bearing field. The agent-bootstrap payload's schemaVersion must
25
+ * stay pinned to this value; `doctor` fails the `catalog-schema` check
26
+ * when they drift.
27
+ */
28
+ export const CATALOG_SCHEMA_VERSION = '1.0';
29
+ /**
30
+ * Human-readable descriptions for common status fields. Populated from
31
+ * the SwitchBot API v1.1 docs. Used by deriveStatusQueries() so every
32
+ * query has a meaningful description even when the entry itself only
33
+ * declares the field name.
34
+ */
35
+ const STATUS_FIELD_DESCRIPTIONS = {
36
+ power: 'Power state (on/off)',
37
+ battery: 'Battery percentage (0-100)',
38
+ version: 'Firmware version string',
39
+ temperature: 'Ambient temperature (°C)',
40
+ humidity: 'Ambient humidity (% RH)',
41
+ CO2: 'CO2 concentration (ppm)',
42
+ brightness: 'Current brightness (0-100)',
43
+ color: 'Current RGB color (r:g:b)',
44
+ colorTemperature: 'Color temperature in Kelvin',
45
+ mode: 'Operating mode',
46
+ deviceMode: 'Hardware mode (Bot-specific)',
47
+ lockState: 'Lock state (locked/unlocked)',
48
+ doorState: 'Door contact state (open/closed)',
49
+ calibrate: 'Calibration status',
50
+ moving: 'Motion in progress (boolean)',
51
+ slidePosition: 'Slide position (0-100)',
52
+ group: 'Multi-device group membership',
53
+ direction: 'Tilt direction',
54
+ voltage: 'Line voltage',
55
+ electricCurrent: 'Instantaneous current draw',
56
+ electricityOfDay: 'kWh consumed today',
57
+ usedElectricity: 'Cumulative kWh',
58
+ useTime: 'Total runtime (seconds)',
59
+ weight: 'Load / weight reading',
60
+ switchStatus: 'Relay state (integer encoded)',
61
+ switch1Status: 'Channel 1 relay state',
62
+ switch2Status: 'Channel 2 relay state',
63
+ workingStatus: 'Device working status (vacuum/purifier)',
64
+ onlineStatus: 'Online / offline (string)',
65
+ online: 'Online / offline (boolean or int)',
66
+ taskType: 'Current task identifier',
67
+ nightStatus: 'Night-mode status',
68
+ oscillation: 'Horizontal oscillation on/off',
69
+ verticalOscillation: 'Vertical oscillation on/off',
70
+ chargingStatus: 'Charging (boolean)',
71
+ fanSpeed: 'Current fan speed level',
72
+ nebulizationEfficiency: 'Humidifier mist level',
73
+ childLock: 'Child-lock engaged',
74
+ sound: 'Beep / audio feedback enabled',
75
+ lackWater: 'Water tank low (boolean)',
76
+ filterElement: 'Filter life remaining',
77
+ auto: 'Auto mode enabled',
78
+ targetTemperature: 'Thermostat target temperature',
79
+ moveDetected: 'Motion detected (boolean)',
80
+ openState: 'Contact sensor open/closed',
81
+ status: 'Device-specific status word',
82
+ lightLevel: 'Ambient light level',
83
+ };
84
+ /**
85
+ * P11: derive the read-only query list for an entry. If the entry has
86
+ * explicit `statusQueries`, return them as-is; otherwise synthesize one
87
+ * ReadOnlyQuerySpec per `statusFields` entry, all keyed to the `status`
88
+ * endpoint. IR-category entries have no status channel so return [].
89
+ */
90
+ export function deriveStatusQueries(entry) {
91
+ if (entry.statusQueries && entry.statusQueries.length > 0)
92
+ return entry.statusQueries;
93
+ if (entry.category === 'ir')
94
+ return [];
95
+ const fields = entry.statusFields ?? [];
96
+ return fields.map((f) => ({
97
+ field: f,
98
+ description: STATUS_FIELD_DESCRIPTIONS[f] ?? `${f} (see API docs)`,
99
+ endpoint: 'status',
100
+ safetyTier: 'read',
101
+ }));
102
+ }
18
103
  // ---- Command fragments (reused across entries) -------------------------
19
104
  const onOff = [
20
105
  { command: 'turnOn', parameter: '—', description: 'Power on', idempotent: true },
@@ -64,7 +149,7 @@ export const DEVICE_CATALOG = [
64
149
  aliases: ['Smart Lock Pro'],
65
150
  commands: [
66
151
  { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
67
- { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
152
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
68
153
  { command: 'deadbolt', parameter: '—', description: 'Pro only: engage deadbolt', idempotent: true },
69
154
  ],
70
155
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
@@ -76,7 +161,7 @@ export const DEVICE_CATALOG = [
76
161
  role: 'security',
77
162
  commands: [
78
163
  { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
79
- { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
164
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
80
165
  ],
81
166
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
82
167
  },
@@ -87,7 +172,7 @@ export const DEVICE_CATALOG = [
87
172
  role: 'security',
88
173
  commands: [
89
174
  { command: 'lock', parameter: '—', description: 'Lock the door', idempotent: true },
90
- { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, destructive: true, destructiveReason: 'Physically unlocks the door — anyone nearby can open it.' },
175
+ { command: 'unlock', parameter: '—', description: 'Unlock the door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Physically unlocks the door — anyone nearby can open it.' },
91
176
  { command: 'deadbolt', parameter: '—', description: 'Engage deadbolt', idempotent: true },
92
177
  ],
93
178
  statusFields: ['battery', 'version', 'lockState', 'doorState', 'calibrate'],
@@ -306,8 +391,8 @@ export const DEVICE_CATALOG = [
306
391
  description: 'Cloud-connected garage door controller; turnOn opens and turnOff closes the door.',
307
392
  role: 'security',
308
393
  commands: [
309
- { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, destructive: true, destructiveReason: 'Opens the garage door — anyone nearby can enter the space.' },
310
- { 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.' },
394
+ { command: 'turnOn', parameter: '—', description: 'Open the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Opens the garage door — anyone nearby can enter the space.' },
395
+ { command: 'turnOff', parameter: '—', description: 'Close the garage door', idempotent: true, safetyTier: 'destructive', safetyReason: 'Closes the garage door — verify no person or obstacle is in the way.' },
311
396
  ],
312
397
  statusFields: ['switchStatus', 'version', 'online'],
313
398
  },
@@ -329,8 +414,8 @@ export const DEVICE_CATALOG = [
329
414
  role: 'security',
330
415
  aliases: ['Keypad Touch'],
331
416
  commands: [
332
- { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', 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.' },
333
- { command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, destructive: true, destructiveReason: 'Permanently removes a passcode — the holder immediately loses door access.' },
417
+ { command: 'createKey', parameter: '\'{"name":"...","type":"permanent|timeLimit|disposable|urgent","password":"6-12 digits","startTime":<s>,"endTime":<s>}\'', description: 'Create a passcode (async; result via webhook)', idempotent: false, safetyTier: 'destructive', safetyReason: 'Provisions a new access credential — anyone with this passcode can unlock the door.' },
418
+ { command: 'deleteKey', parameter: '\'{"id":<passcode_id>}\'', description: 'Delete a passcode (async; result via webhook)', idempotent: true, safetyTier: 'destructive', safetyReason: 'Permanently removes a passcode — the holder immediately loses door access.' },
334
419
  ],
335
420
  statusFields: ['version'],
336
421
  },
@@ -538,13 +623,41 @@ export function findCatalogEntry(query) {
538
623
  return matches[0];
539
624
  return matches;
540
625
  }
626
+ /**
627
+ * Derive the safety tier for a catalog command, honouring an explicit
628
+ * `safetyTier` when present and falling back to heuristic inference.
629
+ *
630
+ * The inference order is:
631
+ * 1. Explicit `spec.safetyTier`.
632
+ * 2. Legacy `spec.destructive: true` → `'destructive'` (overlay compat).
633
+ * 3. IR context (customize command OR entry.category === 'ir')
634
+ * → `'ir-fire-forget'`.
635
+ * 4. Default → `'mutation'`.
636
+ */
637
+ export function deriveSafetyTier(spec, entry) {
638
+ if (spec.safetyTier)
639
+ return spec.safetyTier;
640
+ if (spec.destructive)
641
+ return 'destructive';
642
+ if (spec.commandType === 'customize')
643
+ return 'ir-fire-forget';
644
+ if (entry?.category === 'ir')
645
+ return 'ir-fire-forget';
646
+ return 'mutation';
647
+ }
648
+ /** Read the safety reason for a command, with fallback to the legacy field. */
649
+ export function getCommandSafetyReason(spec) {
650
+ return spec.safetyReason ?? spec.destructiveReason ?? null;
651
+ }
541
652
  /**
542
653
  * Pick up to 3 non-destructive, idempotent commands an agent can safely invoke
543
654
  * to explore or exercise a device. Used by `devices describe --json` to hint
544
655
  * at concrete next steps.
545
656
  */
546
657
  export function suggestedActions(entry) {
547
- const safe = entry.commands.filter((c) => c.idempotent === true && !c.destructive && c.commandType !== 'customize');
658
+ const safe = entry.commands.filter((c) => c.idempotent === true &&
659
+ deriveSafetyTier(c, entry) !== 'destructive' &&
660
+ c.commandType !== 'customize');
548
661
  const picks = [];
549
662
  const seen = new Set();
550
663
  for (const c of safe) {
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Declarative metadata for non-device resources exposed by the SwitchBot API:
3
+ * scenes, webhooks, and keypad credentials ("keys").
4
+ *
5
+ * Consumed by `capabilities --json` and `schema export` so AI agents can
6
+ * discover these surfaces the same way they discover device commands.
7
+ *
8
+ * Scope:
9
+ * - Descriptive metadata only (no runtime execution — CLI/MCP handlers stay
10
+ * source-of-truth for behavior).
11
+ * - Webhook event list is derived from the device catalog and is advisory —
12
+ * not every SwitchBot device actually pushes every listed event; refer to
13
+ * the SwitchBot webhook docs for authoritative shapes.
14
+ */
15
+ const COMMON_WEBHOOK_FIELDS = [
16
+ { name: 'deviceType', type: 'string', description: 'SwitchBot device type string', example: 'WoMeter' },
17
+ { name: 'deviceMac', type: 'string', description: 'Bluetooth MAC address (uppercase, colon-separated)', example: 'AA:BB:CC:11:22:33' },
18
+ { name: 'timeOfSample', type: 'timestamp', description: 'Millisecond Unix timestamp when the sample was taken', example: 1700000000000 },
19
+ ];
20
+ export const RESOURCE_CATALOG = {
21
+ scenes: {
22
+ description: 'Manual scenes (IFTTT-style rules) authored in the SwitchBot app. Execution is fire-and-forget from the cloud — side-effects happen on the user\'s devices.',
23
+ operations: [
24
+ {
25
+ verb: 'list',
26
+ method: 'GET',
27
+ endpoint: '/v1.1/scenes',
28
+ params: [],
29
+ safetyTier: 'read',
30
+ },
31
+ {
32
+ verb: 'execute',
33
+ method: 'POST',
34
+ endpoint: '/v1.1/scenes/{sceneId}/execute',
35
+ params: [{ name: 'sceneId', required: true, type: 'string' }],
36
+ safetyTier: 'mutation',
37
+ },
38
+ {
39
+ verb: 'describe',
40
+ method: 'GET',
41
+ endpoint: '/v1.1/scenes/{sceneId}',
42
+ params: [{ name: 'sceneId', required: true, type: 'string' }],
43
+ safetyTier: 'read',
44
+ },
45
+ ],
46
+ },
47
+ webhooks: {
48
+ endpoints: [
49
+ {
50
+ verb: 'setup',
51
+ method: 'POST',
52
+ path: '/v1.1/webhook/setupWebhook',
53
+ safetyTier: 'mutation',
54
+ requiredParams: ['url'],
55
+ },
56
+ {
57
+ verb: 'query',
58
+ method: 'POST',
59
+ path: '/v1.1/webhook/queryWebhook',
60
+ safetyTier: 'read',
61
+ requiredParams: ['action'],
62
+ },
63
+ {
64
+ verb: 'update',
65
+ method: 'POST',
66
+ path: '/v1.1/webhook/updateWebhook',
67
+ safetyTier: 'mutation',
68
+ requiredParams: ['url', 'enable'],
69
+ },
70
+ {
71
+ verb: 'delete',
72
+ method: 'POST',
73
+ path: '/v1.1/webhook/deleteWebhook',
74
+ safetyTier: 'destructive',
75
+ requiredParams: ['url'],
76
+ },
77
+ ],
78
+ events: [
79
+ {
80
+ eventType: 'WoMeter',
81
+ devicePattern: 'Meter / Meter Plus / Indoor-Outdoor Meter',
82
+ fields: [
83
+ ...COMMON_WEBHOOK_FIELDS,
84
+ { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius', example: 22.5 },
85
+ { name: 'humidity', type: 'number', description: 'Relative humidity (%)', example: 45 },
86
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)', example: 88 },
87
+ ],
88
+ },
89
+ {
90
+ eventType: 'WoCO2Sensor',
91
+ devicePattern: 'CO2 Monitor',
92
+ fields: [
93
+ ...COMMON_WEBHOOK_FIELDS,
94
+ { name: 'CO2', type: 'number', description: 'CO2 concentration in ppm', example: 520 },
95
+ { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
96
+ { name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
97
+ ],
98
+ },
99
+ {
100
+ eventType: 'WoPresence',
101
+ devicePattern: 'Motion Sensor / Video Doorbell motion',
102
+ fields: [
103
+ ...COMMON_WEBHOOK_FIELDS,
104
+ { name: 'detectionState', type: 'string', description: 'Detection result word', example: 'DETECTED' },
105
+ ],
106
+ },
107
+ {
108
+ eventType: 'WoContact',
109
+ devicePattern: 'Contact Sensor',
110
+ fields: [
111
+ ...COMMON_WEBHOOK_FIELDS,
112
+ { name: 'openState', type: 'string', description: 'Door/window state', example: 'open' },
113
+ { name: 'moveDetected', type: 'boolean', description: 'Motion detected during this sample' },
114
+ ],
115
+ },
116
+ {
117
+ eventType: 'WoLock',
118
+ devicePattern: 'Smart Lock / Smart Lock Lite / Smart Lock Pro',
119
+ fields: [
120
+ ...COMMON_WEBHOOK_FIELDS,
121
+ { name: 'lockState', type: 'string', description: 'Lock state: locked, unlocked, jammed', example: 'locked' },
122
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
123
+ ],
124
+ },
125
+ {
126
+ eventType: 'WoPlug',
127
+ devicePattern: 'Plug Mini / Plug / Relay Switch',
128
+ fields: [
129
+ ...COMMON_WEBHOOK_FIELDS,
130
+ { name: 'power', type: 'string', description: 'Power state (on/off)', example: 'on' },
131
+ { name: 'voltage', type: 'number', description: 'Instantaneous voltage (V)' },
132
+ { name: 'electricCurrent', type: 'number', description: 'Instantaneous current (A)' },
133
+ ],
134
+ },
135
+ {
136
+ eventType: 'WoBot',
137
+ devicePattern: 'Bot',
138
+ fields: [
139
+ ...COMMON_WEBHOOK_FIELDS,
140
+ { name: 'power', type: 'string', description: 'Power state (on/off)' },
141
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
142
+ ],
143
+ },
144
+ {
145
+ eventType: 'WoCurtain',
146
+ devicePattern: 'Curtain / Blind Tilt / Roller Shade',
147
+ fields: [
148
+ ...COMMON_WEBHOOK_FIELDS,
149
+ { name: 'slidePosition', type: 'number', description: 'Current slide position (0–100)' },
150
+ { name: 'calibrate', type: 'boolean', description: 'True if device is calibrated' },
151
+ ],
152
+ },
153
+ {
154
+ eventType: 'WoDoorbell',
155
+ devicePattern: 'Video Doorbell button press',
156
+ fields: [
157
+ ...COMMON_WEBHOOK_FIELDS,
158
+ { name: 'buttonName', type: 'string', description: 'Identifier of the pressed button' },
159
+ { name: 'pressedAt', type: 'timestamp', description: 'Press timestamp in milliseconds' },
160
+ ],
161
+ },
162
+ {
163
+ eventType: 'WoKeypad',
164
+ devicePattern: 'Keypad scan / createKey result / deleteKey result',
165
+ fields: [
166
+ ...COMMON_WEBHOOK_FIELDS,
167
+ { name: 'eventType', type: 'string', description: 'Sub-event (createKey / deleteKey / invalidCode)' },
168
+ { name: 'commandId', type: 'string', description: 'Correlation id returned by the original command' },
169
+ { name: 'result', type: 'string', description: 'Outcome (success / failed / timeout)' },
170
+ ],
171
+ },
172
+ {
173
+ eventType: 'WoColorBulb',
174
+ devicePattern: 'Color Bulb',
175
+ fields: [
176
+ ...COMMON_WEBHOOK_FIELDS,
177
+ { name: 'power', type: 'string', description: 'Power state (on/off)' },
178
+ { name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
179
+ { name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
180
+ { name: 'colorTemperature', type: 'number', description: 'Color temperature in Kelvin' },
181
+ ],
182
+ },
183
+ {
184
+ eventType: 'WoStrip',
185
+ devicePattern: 'Strip Light',
186
+ fields: [
187
+ ...COMMON_WEBHOOK_FIELDS,
188
+ { name: 'power', type: 'string', description: 'Power state (on/off)' },
189
+ { name: 'brightness', type: 'number', description: 'Brightness (0–100)' },
190
+ { name: 'color', type: 'string', description: 'RGB triplet "r:g:b"' },
191
+ ],
192
+ },
193
+ {
194
+ eventType: 'WoSweeper',
195
+ devicePattern: 'Robot Vacuum',
196
+ fields: [
197
+ ...COMMON_WEBHOOK_FIELDS,
198
+ { name: 'workingStatus', type: 'string', description: 'Cleaning state' },
199
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
200
+ { name: 'taskType', type: 'string', description: 'Current task (standby / clean / charge)' },
201
+ ],
202
+ },
203
+ {
204
+ eventType: 'WoWaterLeakDetect',
205
+ devicePattern: 'Water Leak Detector',
206
+ fields: [
207
+ ...COMMON_WEBHOOK_FIELDS,
208
+ { name: 'waterLeakDetect', type: 'number', description: 'Leak flag (0 = dry, 1 = leak detected)' },
209
+ { name: 'battery', type: 'number', description: 'Battery remaining (%)' },
210
+ ],
211
+ },
212
+ {
213
+ eventType: 'WoHub',
214
+ devicePattern: 'Hub 2 / Hub 3 (ambient sensors)',
215
+ fields: [
216
+ ...COMMON_WEBHOOK_FIELDS,
217
+ { name: 'temperature', type: 'number', description: 'Ambient temperature in Celsius' },
218
+ { name: 'humidity', type: 'number', description: 'Relative humidity (%)' },
219
+ { name: 'lightLevel', type: 'number', description: 'Illuminance level' },
220
+ ],
221
+ },
222
+ ],
223
+ constraints: {
224
+ maxUrlLength: 2048,
225
+ maxWebhooksPerAccount: 1,
226
+ },
227
+ },
228
+ keys: [
229
+ {
230
+ keyType: 'permanent',
231
+ description: 'Passcode that never expires — valid until manually deleted.',
232
+ requiredParams: ['name', 'password'],
233
+ optionalParams: [],
234
+ supportedDevices: ['Keypad', 'Keypad Touch'],
235
+ safetyTier: 'destructive',
236
+ },
237
+ {
238
+ keyType: 'timeLimit',
239
+ description: 'Passcode valid only between startTime and endTime (Unix seconds).',
240
+ requiredParams: ['name', 'password', 'startTime', 'endTime'],
241
+ optionalParams: [],
242
+ supportedDevices: ['Keypad', 'Keypad Touch'],
243
+ safetyTier: 'destructive',
244
+ },
245
+ {
246
+ keyType: 'disposable',
247
+ description: 'Passcode that can be used once and then auto-expires.',
248
+ requiredParams: ['name', 'password'],
249
+ optionalParams: ['startTime', 'endTime'],
250
+ supportedDevices: ['Keypad', 'Keypad Touch'],
251
+ safetyTier: 'destructive',
252
+ },
253
+ {
254
+ keyType: 'urgent',
255
+ description: 'Emergency passcode (typically tied to panic / audit workflow).',
256
+ requiredParams: ['name', 'password'],
257
+ optionalParams: [],
258
+ supportedDevices: ['Keypad', 'Keypad Touch'],
259
+ safetyTier: 'destructive',
260
+ },
261
+ ],
262
+ };
263
+ /** Convenience: return the list of known webhook event types. */
264
+ export function listWebhookEventTypes() {
265
+ return RESOURCE_CATALOG.webhooks.events.map((e) => e.eventType);
266
+ }
267
+ /** Convenience: return the list of supported keypad key types. */
268
+ export function listKeyTypes() {
269
+ return RESOURCE_CATALOG.keys.map((k) => k.keyType);
270
+ }
package/dist/index.js CHANGED
@@ -4,7 +4,9 @@ import { createRequire } from 'node:module';
4
4
  import chalk from 'chalk';
5
5
  import { intArg, stringArg, enumArg } from './utils/arg-parsers.js';
6
6
  import { parseDurationToMs } from './utils/flags.js';
7
- import { emitJsonError, isJsonMode } from './utils/output.js';
7
+ import { emitJsonError, isJsonMode, printJson } from './utils/output.js';
8
+ import { commandToJson, resolveTargetCommand } from './utils/help-json.js';
9
+ import { PRODUCT_TAGLINE } from './commands/identity.js';
8
10
  import { registerConfigCommand } from './commands/config.js';
9
11
  import { registerDevicesCommand } from './commands/devices.js';
10
12
  import { registerScenesCommand } from './commands/scenes.js';
@@ -54,7 +56,7 @@ const cacheModeArg = (value) => {
54
56
  };
55
57
  program
56
58
  .name('switchbot')
57
- .description('Command-line tool for SwitchBot API v1.1')
59
+ .description(PRODUCT_TAGLINE)
58
60
  .version(pkgVersion)
59
61
  .option('--no-color', 'Disable ANSI colors in output')
60
62
  .option('--json', 'Output raw JSON response (disables tables; useful for pipes/scripts)')
@@ -150,6 +152,10 @@ function enableSuggestions(cmd) {
150
152
  cmd.commands.forEach(enableSuggestions);
151
153
  }
152
154
  enableSuggestions(program);
155
+ // In JSON mode suppress the plain-text help output so we can emit structured JSON instead.
156
+ if (isJsonMode()) {
157
+ program.configureOutput({ writeOut: () => { } });
158
+ }
153
159
  try {
154
160
  await program.parseAsync();
155
161
  }
@@ -158,7 +164,14 @@ catch (err) {
158
164
  // argParser on a subcommand option) don't always hit the root exitOverride.
159
165
  // Mirror the root mapping so all usage errors surface as exit 2.
160
166
  if (err instanceof CommanderError) {
161
- if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
167
+ if (err.code === 'commander.helpDisplayed') {
168
+ if (isJsonMode()) {
169
+ const target = resolveTargetCommand(program, process.argv.slice(2));
170
+ printJson(commandToJson(target, { includeIdentity: target === program }));
171
+ }
172
+ process.exit(0);
173
+ }
174
+ if (err.code === 'commander.version') {
162
175
  process.exit(0);
163
176
  }
164
177
  if (isJsonMode()) {
@@ -1,6 +1,6 @@
1
1
  import { createClient } from '../api/client.js';
2
2
  import { idempotencyCache } from './idempotency.js';
3
- import { findCatalogEntry, suggestedActions, getEffectiveCatalog, } from '../devices/catalog.js';
3
+ import { findCatalogEntry, suggestedActions, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
4
4
  import { getCachedDevice, updateCacheFromDeviceList, loadCache, isListCacheFresh, getCachedStatus, setCachedStatus, } from '../devices/cache.js';
5
5
  import { getCacheMode } from '../utils/flags.js';
6
6
  import { writeAudit } from '../utils/audit.js';
@@ -216,9 +216,11 @@ export function isDestructiveCommand(deviceType, cmd, commandType) {
216
216
  if (!match || Array.isArray(match))
217
217
  return false;
218
218
  const spec = match.commands.find((c) => c.command === cmd);
219
- return Boolean(spec?.destructive);
219
+ if (!spec)
220
+ return false;
221
+ return deriveSafetyTier(spec, match) === 'destructive';
220
222
  }
221
- /** Return the destructiveReason for a command, or null if not destructive / not found. */
223
+ /** Return the safetyReason for a command, or null if not destructive / not found. */
222
224
  export function getDestructiveReason(deviceType, cmd, commandType) {
223
225
  if (commandType === 'customize')
224
226
  return null;
@@ -228,7 +230,7 @@ export function getDestructiveReason(deviceType, cmd, commandType) {
228
230
  if (!match || Array.isArray(match))
229
231
  return null;
230
232
  const spec = match.commands.find((c) => c.command === cmd);
231
- return spec?.destructiveReason ?? null;
233
+ return spec ? getCommandSafetyReason(spec) : null;
232
234
  }
233
235
  /**
234
236
  * Describe a device by id: metadata + catalog entry (if known) +
@@ -264,7 +266,16 @@ export async function describeDevice(deviceId, options = {}, client) {
264
266
  ? {
265
267
  role: catalogEntry.role ?? null,
266
268
  readOnly: catalogEntry.readOnly ?? false,
267
- commands: catalogEntry.commands,
269
+ commands: catalogEntry.commands.map((c) => {
270
+ const tier = deriveSafetyTier(c, catalogEntry);
271
+ const reason = getCommandSafetyReason(c);
272
+ return {
273
+ ...c,
274
+ safetyTier: tier,
275
+ destructive: tier === 'destructive',
276
+ ...(reason ? { safetyReason: reason } : {}),
277
+ };
278
+ }),
268
279
  statusFields: catalogEntry.statusFields ?? [],
269
280
  ...(liveStatus !== undefined ? { liveStatus } : {}),
270
281
  }