@switchbot/openapi-cli 2.6.2 → 2.6.4

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.
package/README.md CHANGED
@@ -184,7 +184,10 @@ switchbot devices command --help
184
184
 
185
185
  Intercepts every non-GET request: the CLI prints the URL/body it would have
186
186
  sent, then exits `0` without contacting the API. `GET` requests (list, status,
187
- query) are still executed so you can preview the state involved.
187
+ query) are still executed so you can preview the state involved. Dry-run also
188
+ validates command names against the device catalog and rejects unknown commands
189
+ (exit 2) when the device type has a known catalog entry. Commands sent to
190
+ read-only sensors (e.g. Meter) are likewise rejected.
188
191
 
189
192
  ```bash
190
193
  switchbot devices command ABC123 turnOn --dry-run
@@ -305,7 +308,7 @@ Generic parameter shapes (which one applies is decided by the device — see the
305
308
 
306
309
  Parameters for `setAll` (Air Conditioner), `setPosition` (Curtain / Blind Tilt), `setMode` (Relay Switch), `setBrightness` (dimmable lights), and `setColor` (Color Bulb / Strip Light / Ceiling Light) are validated client-side before the request — malformed shapes, out-of-range values, and JSON for CSV fields all fail fast with exit 2. `setColor` accepts `R:G:B`, `R,G,B`, `#RRGGBB`, `#RGB`, and CSS named colors (`red`, `blue`, …); all normalize to `R:G:B` before hitting the API. Pass `--skip-param-validation` to bypass (escape hatch — prefer fixing the argument). Command names are also case-normalized against the catalog (e.g. `turnon` is auto-corrected to `turnOn` with a stderr warning); unknown names still exit 2 with the supported-commands list.
307
310
 
308
- Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through.
311
+ Unknown deviceIds (not in the local cache) exit 2 by default so `--dry-run` is a reliable pre-flight gate. Unknown command names and commands on read-only sensors are also rejected during dry-run when the device type has a catalog entry. Run `switchbot devices list` first, or pass `--allow-unknown-device` for scripted pass-through.
309
312
 
310
313
  Negative numeric parameters (e.g. `setBrightness -1` for a probe) are passed through to the command validator instead of being swallowed by the flag parser as an unknown option.
311
314
 
@@ -717,7 +720,7 @@ switchbot cache clear --key status
717
720
 
718
721
  | Code | Meaning |
719
722
  | ---- | ------------------------------------------------------------------------------------------------------------------------- |
720
- | `0` | Success (including `--dry-run` intercept) |
723
+ | `0` | Success (including `--dry-run` intercept when validation passes) |
721
724
  | `1` | Runtime error — API error, network failure, missing credentials |
722
725
  | `2` | Usage error — bad flag, missing/invalid argument, unknown subcommand, unknown device type, invalid URL, conflicting flags |
723
726
 
@@ -1,5 +1,5 @@
1
1
  import { intArg, enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError } from '../utils/output.js';
2
+ import { printJson, isJsonMode, handleError, buildErrorPayload, UsageError, emitJsonError, exitWithError } from '../utils/output.js';
3
3
  import { fetchDeviceList, executeCommand, isDestructiveCommand, buildHubLocationMap, } from '../lib/devices.js';
4
4
  import { createClient } from '../api/client.js';
5
5
  import { parseFilter, applyFilter, FilterSyntaxError } from '../utils/filter.js';
@@ -166,22 +166,10 @@ Examples:
166
166
  }
167
167
  catch (error) {
168
168
  if (error instanceof FilterSyntaxError) {
169
- if (isJsonMode()) {
170
- emitJsonError({ code: 2, kind: 'usage', message: error.message });
171
- }
172
- else {
173
- console.error(`Error: ${error.message}`);
174
- }
175
- process.exit(2);
169
+ exitWithError(`Error: ${error.message}`);
176
170
  }
177
171
  if (error instanceof Error && error.message.startsWith('No target devices')) {
178
- if (isJsonMode()) {
179
- emitJsonError({ code: 2, kind: 'usage', message: error.message });
180
- }
181
- else {
182
- console.error(`Error: ${error.message}`);
183
- }
184
- process.exit(2);
172
+ exitWithError(`Error: ${error.message}`);
185
173
  }
186
174
  handleError(error);
187
175
  }
@@ -74,6 +74,14 @@ Examples:
74
74
  .description("Show the effective catalog (or one entry). Alias: 'list'. Defaults to 'effective' source.")
75
75
  .argument('[type...]', 'Optional device type/alias (case-insensitive, partial match)')
76
76
  .option('--source <source>', 'Which catalog to show: built-in | overlay | effective (default)', enumArg('--source', SOURCES), 'effective')
77
+ .addHelpText('after', `
78
+ Examples:
79
+ $ switchbot catalog show
80
+ $ switchbot catalog show Bot
81
+ $ switchbot catalog show Robot Vacuum
82
+ $ switchbot catalog show --source built-in
83
+ $ switchbot catalog show --json
84
+ `)
77
85
  .action((typeParts, options) => {
78
86
  try {
79
87
  const source = options.source;
@@ -3,7 +3,7 @@ import readline from 'node:readline';
3
3
  import { execFileSync } from 'node:child_process';
4
4
  import { stringArg } from '../utils/arg-parsers.js';
5
5
  import { intArg } from '../utils/arg-parsers.js';
6
- import { saveConfig, showConfig, listProfiles, readProfileMeta } from '../config.js';
6
+ import { saveConfig, showConfig, getConfigSummary, listProfiles, readProfileMeta } from '../config.js';
7
7
  import { isJsonMode, printJson, emitJsonError } from '../utils/output.js';
8
8
  import chalk from 'chalk';
9
9
  function parseEnvFile(file) {
@@ -237,6 +237,10 @@ Files are written with mode 0600. Profiles live under ~/.switchbot/profiles/<nam
237
237
  .command('show')
238
238
  .description('Show the current credential source and a masked secret')
239
239
  .action(() => {
240
+ if (isJsonMode()) {
241
+ printJson(getConfigSummary());
242
+ return;
243
+ }
240
244
  showConfig();
241
245
  });
242
246
  config
@@ -1,5 +1,5 @@
1
1
  import { enumArg, stringArg } from '../utils/arg-parsers.js';
2
- import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError } from '../utils/output.js';
2
+ import { printTable, printKeyValue, printJson, isJsonMode, handleError, UsageError, StructuredUsageError, emitJsonError, exitWithError } from '../utils/output.js';
3
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
4
4
  import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
5
5
  import { getCachedDevice, loadCache } from '../devices/cache.js';
@@ -15,6 +15,14 @@ import { registerExpandCommand } from './expand.js';
15
15
  import { registerDevicesMetaCommand } from './device-meta.js';
16
16
  import { isDryRun } from '../utils/flags.js';
17
17
  import { DryRunSignal } from '../api/client.js';
18
+ import { resolveField, listSupportedFieldInputs } from '../schema/field-aliases.js';
19
+ const EXPAND_HINTS = {
20
+ 'Air Conditioner': { command: 'setAll', flags: '--temp 26 --mode cool --fan low --power on' },
21
+ 'Curtain': { command: 'setPosition', flags: '--position 50 --mode silent' },
22
+ 'Curtain 3': { command: 'setPosition', flags: '--position 50' },
23
+ 'Blind Tilt': { command: 'setPosition', flags: '--direction up --angle 50' },
24
+ 'Relay Switch 2PM': { command: 'setMode', flags: '--channel 1 --mode edge' },
25
+ };
18
26
  export function registerDevicesCommand(program) {
19
27
  const COMMAND_TYPES = ['command', 'customize'];
20
28
  const devices = program
@@ -80,7 +88,7 @@ Examples:
80
88
  `)
81
89
  .option('--wide', 'Show all columns (controlType, family, roomID, room, hub, cloud)')
82
90
  .option('--show-hidden', 'Include devices hidden via "devices meta set --hide"')
83
- .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: type, name, category, room.', stringArg('--filter'))
91
+ .option('--filter <expr>', 'Filter devices: comma-separated clauses. Each clause is "key=value" (substring; exact for category), "key!=value" (negated substring), "key~value" (explicit substring), or "key=/regex/" (case-insensitive regex). Supported keys: deviceId/id, deviceName/name, deviceType/type, controlType, roomName/room, category.', stringArg('--filter'))
84
92
  .action(async (options) => {
85
93
  try {
86
94
  const body = await fetchDeviceList();
@@ -90,11 +98,26 @@ Examples:
90
98
  const hubLocation = buildHubLocationMap(deviceList);
91
99
  // Parse --filter into a list of clauses. Shared grammar across
92
100
  // `devices list`, `devices batch`, and `events tail` / `mqtt-tail`.
93
- const LIST_KEYS = ['type', 'name', 'category', 'room'];
101
+ const LIST_KEYS = ['deviceId', 'type', 'name', 'category', 'room', 'controlType'];
102
+ const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType', 'roomName', 'category'];
103
+ const LIST_FILTER_TO_RUNTIME = {
104
+ deviceId: 'deviceId',
105
+ deviceName: 'name',
106
+ deviceType: 'type',
107
+ controlType: 'controlType',
108
+ roomName: 'room',
109
+ category: 'category',
110
+ };
94
111
  let listClauses = null;
95
112
  if (options.filter) {
96
113
  try {
97
- listClauses = parseFilterExpr(options.filter, LIST_KEYS);
114
+ listClauses = parseFilterExpr(options.filter, LIST_KEYS, {
115
+ resolveKey: (input) => {
116
+ const canonical = resolveField(input, LIST_FILTER_CANONICAL);
117
+ return LIST_FILTER_TO_RUNTIME[canonical];
118
+ },
119
+ supportedKeys: listSupportedFieldInputs(LIST_FILTER_CANONICAL),
120
+ });
98
121
  }
99
122
  catch (err) {
100
123
  if (err instanceof FilterSyntaxError)
@@ -114,10 +137,10 @@ Examples:
114
137
  };
115
138
  if (fmt === 'json' && process.argv.includes('--json')) {
116
139
  if (listClauses) {
117
- const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
140
+ const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }));
118
141
  const filteredIrList = infraredRemoteList.filter((d) => {
119
142
  const inherited = hubLocation.get(d.hubDeviceId);
120
- return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' });
143
+ return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' });
121
144
  });
122
145
  printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
123
146
  }
@@ -134,7 +157,7 @@ Examples:
134
157
  for (const d of deviceList) {
135
158
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
136
159
  continue;
137
- if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }))
160
+ if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '' }))
138
161
  continue;
139
162
  rows.push([
140
163
  d.deviceId,
@@ -154,7 +177,7 @@ Examples:
154
177
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
155
178
  continue;
156
179
  const inherited = hubLocation.get(d.hubDeviceId);
157
- if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }))
180
+ if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '' }))
158
181
  continue;
159
182
  rows.push([
160
183
  d.deviceId,
@@ -177,9 +200,19 @@ Examples:
177
200
  const defaultFields = options.wide ? undefined : narrowHeaders;
178
201
  // Accept API field names and short aliases alongside canonical column names
179
202
  const DEVICE_LIST_ALIASES = {
180
- id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type',
181
- roomName: 'room', familyName: 'family',
182
- hubDeviceId: 'hub', enableCloudService: 'cloud',
203
+ id: 'deviceId',
204
+ name: 'deviceName',
205
+ deviceType: 'type',
206
+ type: 'type',
207
+ roomName: 'room',
208
+ familyName: 'family',
209
+ hubDeviceId: 'hub',
210
+ enableCloudService: 'cloud',
211
+ controlType: 'controlType',
212
+ deviceName: 'deviceName',
213
+ deviceId: 'deviceId',
214
+ category: 'category',
215
+ alias: 'alias',
183
216
  };
184
217
  renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
185
218
  if (fmt === 'table') {
@@ -454,18 +487,10 @@ Examples:
454
487
  if (cachedForParam && options.type === 'command' && !options.skipParamValidation) {
455
488
  const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
456
489
  if (!paramCheck.ok) {
457
- if (isJsonMode()) {
458
- emitJsonError({
459
- code: 2,
460
- kind: 'usage',
461
- message: paramCheck.error,
462
- context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
463
- });
464
- }
465
- else {
466
- console.error(`Error: ${paramCheck.error}`);
467
- }
468
- process.exit(2);
490
+ exitWithError({
491
+ message: `Error: ${paramCheck.error}`,
492
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
493
+ });
469
494
  }
470
495
  if (paramCheck.normalized !== undefined)
471
496
  parameter = paramCheck.normalized;
@@ -476,24 +501,14 @@ Examples:
476
501
  isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
477
502
  const typeLabel = cachedForGuard?.type ?? 'unknown';
478
503
  const reason = getDestructiveReason(cachedForGuard?.type, cmd, options.type);
479
- if (isJsonMode()) {
480
- emitJsonError({
481
- code: 2,
482
- kind: 'guard',
483
- message: `"${cmd}" on ${typeLabel} is destructive and requires --yes.`,
484
- hint: reason
485
- ? `Re-run with --yes to confirm. Reason: ${reason}`
486
- : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
487
- context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
488
- });
489
- }
490
- else {
491
- console.error(`Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`);
492
- if (reason)
493
- console.error(`Reason: ${reason}`);
494
- console.error(`Re-run with --yes to confirm, or --dry-run to preview without sending.`);
495
- }
496
- process.exit(2);
504
+ exitWithError({
505
+ kind: 'guard',
506
+ message: `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`,
507
+ hint: reason
508
+ ? `Re-run with --yes to confirm. Reason: ${reason}`
509
+ : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
510
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { destructiveReason: reason } : {}) },
511
+ });
497
512
  }
498
513
  // Warn when --yes is given but the command is not destructive (no-op flag)
499
514
  if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
@@ -702,7 +717,8 @@ JSON output shape (--json):
702
717
  liveStatus: <status payload when --live was passed>
703
718
  },
704
719
  source: "catalog" | "live" | "catalog+live" | "none",
705
- suggestedActions: [{command, parameter?, description}]
720
+ suggestedActions: [{command, parameter?, description}],
721
+ expandHint?: {command, flags, example} // present when the type supports 'devices expand'
706
722
  }
707
723
 
708
724
  Examples:
@@ -722,6 +738,7 @@ Examples:
722
738
  const result = await describeDevice(deviceId, options);
723
739
  const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
724
740
  if (isJsonMode()) {
741
+ const expandHint = catalog ? EXPAND_HINTS[catalog.type] : undefined;
725
742
  printJson({
726
743
  device,
727
744
  controlType,
@@ -729,6 +746,7 @@ Examples:
729
746
  capabilities,
730
747
  source,
731
748
  suggestedActions: picks,
749
+ ...(expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}),
732
750
  });
733
751
  return;
734
752
  }
@@ -785,20 +803,12 @@ Examples:
785
803
  catch (error) {
786
804
  if (error instanceof DeviceNotFoundError) {
787
805
  const message = `${error.message} Try 'switchbot devices list' to see the full list.`;
788
- if (isJsonMode()) {
789
- emitJsonError({
790
- code: 1,
791
- kind: 'runtime',
792
- message,
793
- errorClass: 'runtime',
794
- transient: false,
795
- });
796
- }
797
- else {
798
- console.error(error.message);
799
- console.error(`Try 'switchbot devices list' to see the full list.`);
800
- }
801
- process.exit(1);
806
+ exitWithError({
807
+ code: 1,
808
+ kind: 'runtime',
809
+ message,
810
+ extra: { errorClass: 'runtime', transient: false },
811
+ });
802
812
  }
803
813
  handleError(error);
804
814
  }
@@ -853,4 +863,8 @@ function renderCatalogEntry(entry) {
853
863
  console.log('\nStatus fields (from "devices status"):');
854
864
  console.log(' ' + entry.statusFields.join(', '));
855
865
  }
866
+ const expandHint = EXPAND_HINTS[entry.type];
867
+ if (expandHint) {
868
+ console.log(`\nTip: Use 'devices expand <id> ${expandHint.command} ${expandHint.flags}' for semantic flags instead of raw parameters.`);
869
+ }
856
870
  }
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
5
  import { intArg, stringArg } from '../utils/arg-parsers.js';
6
- import { handleError, isJsonMode, buildErrorPayload, emitJsonError } from '../utils/output.js';
6
+ import { handleError, buildErrorPayload, exitWithError } from '../utils/output.js';
7
7
  import { VERSION } from '../version.js';
8
8
  import { fetchDeviceList, fetchDeviceStatus, executeCommand, describeDevice, validateCommand, isDestructiveCommand, getDestructiveReason, searchCatalog, DeviceNotFoundError, toMcpDescribeShape, toMcpDeviceListShape, toMcpIrDeviceShape, } from '../lib/devices.js';
9
9
  import { fetchScenes, executeScene } from '../lib/scenes.js';
@@ -274,6 +274,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
274
274
  },
275
275
  }, async ({ deviceId, command, parameter, commandType, confirm, idempotencyKey, dryRun }) => {
276
276
  const effectiveType = commandType ?? 'command';
277
+ let effectiveCommand = command;
277
278
  let effectiveParameter = parameter;
278
279
  // stringifiedParam mirrors the CLI form that validateCommand /
279
280
  // validateParameter expect — B-1 runs on the string representation.
@@ -291,30 +292,37 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
291
292
  context: { deviceId },
292
293
  });
293
294
  }
295
+ const dryValidation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
296
+ if (!dryValidation.ok) {
297
+ return mcpError('usage', 2, dryValidation.error.message, {
298
+ hint: dryValidation.error.hint,
299
+ context: {
300
+ validationKind: dryValidation.error.kind,
301
+ deviceType: cached.type,
302
+ command: effectiveCommand,
303
+ },
304
+ });
305
+ }
306
+ if (dryValidation.normalized) {
307
+ effectiveCommand = dryValidation.normalized;
308
+ }
294
309
  // R-2: run B-1 param validation in dry-run too, so dry-run doesn't
295
310
  // falsely accept inputs the live API would reject.
296
311
  if (effectiveType !== 'customize') {
297
- const pv = validateParameter(cached.type, command, stringifiedParam);
312
+ const pv = validateParameter(cached.type, effectiveCommand, stringifiedParam);
298
313
  if (!pv.ok) {
299
314
  return mcpError('usage', 2, pv.error, {
300
315
  hint: 'Dry-run rejected the parameter client-side; the API would reject it too.',
301
- context: { deviceType: cached.type, command, parameter: stringifiedParam },
316
+ context: { deviceType: cached.type, command: effectiveCommand, parameter: stringifiedParam },
302
317
  });
303
318
  }
304
319
  if (pv.normalized !== undefined) {
305
320
  effectiveParameter = pv.normalized;
306
321
  }
307
322
  }
308
- const cmdVal = validateCommand(deviceId, command, stringifiedParam, effectiveType);
309
- if (!cmdVal.ok) {
310
- return mcpError('usage', 2, cmdVal.error.message, {
311
- hint: cmdVal.error.hint,
312
- context: { validationKind: cmdVal.error.kind },
313
- });
314
- }
315
323
  const wouldSend = {
316
324
  deviceId,
317
- command,
325
+ command: effectiveCommand,
318
326
  parameter: effectiveParameter ?? 'default',
319
327
  commandType: effectiveType,
320
328
  };
@@ -339,19 +347,19 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
339
347
  }
340
348
  typeName = physical ? physical.deviceType : ir.remoteType;
341
349
  }
342
- if (isDestructiveCommand(typeName, command, effectiveType) && !confirm) {
343
- const reason = getDestructiveReason(typeName, command, effectiveType);
350
+ if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) {
351
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
344
352
  const entry = typeName ? findCatalogEntry(typeName) : null;
345
353
  const spec = entry && !Array.isArray(entry)
346
- ? entry.commands.find((c) => c.command === command)
354
+ ? entry.commands.find((c) => c.command === effectiveCommand)
347
355
  : undefined;
348
356
  const hint = reason
349
357
  ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}`
350
358
  : 'Re-issue the call with confirm:true to proceed.';
351
- return mcpError('guard', 3, `Command "${command}" on device type "${typeName}" is destructive and requires confirm:true.`, {
359
+ return mcpError('guard', 3, `Command "${effectiveCommand}" on device type "${typeName}" is destructive and requires confirm:true.`, {
352
360
  hint,
353
361
  context: {
354
- command,
362
+ command: effectiveCommand,
355
363
  deviceType: typeName,
356
364
  description: spec?.description ?? null,
357
365
  ...(reason ? { destructiveReason: reason } : {}),
@@ -361,18 +369,24 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
361
369
  // validateCommand covers command existence + required/unexpected-parameter.
362
370
  // stringifiedParam was computed once at the top of the handler so dry-run
363
371
  // and live paths share the same shape.
364
- const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType);
372
+ const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
365
373
  if (!validation.ok) {
366
- return mcpError('usage', 2, validation.error.message, { hint: validation.error.hint, context: { validationKind: validation.error.kind } });
374
+ return mcpError('usage', 2, validation.error.message, {
375
+ hint: validation.error.hint,
376
+ context: { validationKind: validation.error.kind, deviceType: typeName, command: effectiveCommand },
377
+ });
378
+ }
379
+ if (validation.normalized) {
380
+ effectiveCommand = validation.normalized;
367
381
  }
368
382
  // R-2: run B-1 client-side parameter validator (range/format checks).
369
383
  // Customize commands (user-defined IR buttons) opt out — the catalog
370
384
  // cannot know their expected shape.
371
385
  if (effectiveType !== 'customize') {
372
- const pv = validateParameter(typeName, command, stringifiedParam);
386
+ const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
373
387
  if (!pv.ok) {
374
388
  return mcpError('usage', 2, pv.error, {
375
- context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
389
+ context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
376
390
  });
377
391
  }
378
392
  if (pv.normalized !== undefined) {
@@ -381,7 +395,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
381
395
  }
382
396
  let result;
383
397
  try {
384
- result = await executeCommand(deviceId, command, effectiveParameter, effectiveType, undefined, {
398
+ result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, {
385
399
  idempotencyKey,
386
400
  });
387
401
  }
@@ -398,7 +412,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
398
412
  return apiErrorToMcpError(err);
399
413
  }
400
414
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
401
- const structured = { ok: true, command, deviceId, result };
415
+ const structured = { ok: true, command: effectiveCommand, deviceId, result };
402
416
  if (isIr) {
403
417
  structured.verification = {
404
418
  verifiable: false,
@@ -764,19 +778,19 @@ Inspect locally:
764
778
  .option('--auth-token <token>', 'Bearer token for HTTP requests (required for --bind 0.0.0.0; falls back to SWITCHBOT_MCP_TOKEN env var)', stringArg('--auth-token'))
765
779
  .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
766
780
  .option('--rate-limit <n>', 'Max requests per minute per profile (default 60)', intArg('--rate-limit', { min: 1 }), '60')
781
+ .addHelpText('after', `
782
+ Examples:
783
+ $ switchbot mcp serve
784
+ $ switchbot mcp serve --port 8787
785
+ $ switchbot mcp serve --port 8787 --bind 127.0.0.1 --auth-token your-token
786
+ $ switchbot mcp serve --port 8787 --bind 0.0.0.0 --auth-token your-token
787
+ `)
767
788
  .action(async (options) => {
768
789
  try {
769
790
  if (options.port) {
770
791
  const port = Number(options.port);
771
792
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
772
- const msg = `Invalid --port "${options.port}". Must be 1-65535.`;
773
- if (isJsonMode()) {
774
- emitJsonError({ code: 2, kind: 'usage', message: msg });
775
- }
776
- else {
777
- console.error(msg);
778
- }
779
- process.exit(2);
793
+ exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
780
794
  }
781
795
  const bind = options.bind ?? '127.0.0.1';
782
796
  const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
@@ -785,14 +799,7 @@ Inspect locally:
785
799
  // Guard: refuse to bind non-localhost without auth
786
800
  const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
787
801
  if (!isLocalhost && !authToken) {
788
- const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).';
789
- if (isJsonMode()) {
790
- emitJsonError({ code: 2, kind: 'usage', message: msg });
791
- }
792
- else {
793
- console.error(msg);
794
- }
795
- process.exit(2);
802
+ exitWithError('Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).');
796
803
  }
797
804
  const { createServer } = await import('node:http');
798
805
  const rateLimitMap = new Map();
@@ -1011,6 +1018,29 @@ process_uptime_seconds ${Math.floor(process.uptime())}
1011
1018
  const server = createSwitchBotMcpServer({ eventManager });
1012
1019
  const transport = new StdioServerTransport();
1013
1020
  await server.connect(transport);
1021
+ let isShuttingDown = false;
1022
+ const gracefulShutdown = async () => {
1023
+ if (isShuttingDown)
1024
+ return;
1025
+ isShuttingDown = true;
1026
+ console.error('Shutting down...');
1027
+ // Force exit after 30s if shutdown hangs (e.g. stuck MQTT disconnect).
1028
+ const forceExit = setTimeout(() => {
1029
+ console.error('Force exiting after 30s timeout');
1030
+ process.exit(1);
1031
+ }, 30000);
1032
+ forceExit.unref();
1033
+ try {
1034
+ await eventManager.shutdown();
1035
+ }
1036
+ catch (err) {
1037
+ console.error('Error during shutdown:', err instanceof Error ? err.message : String(err));
1038
+ }
1039
+ process.exit(0);
1040
+ };
1041
+ process.on('SIGTERM', gracefulShutdown);
1042
+ process.on('SIGINT', gracefulShutdown);
1043
+ process.stdin.on('end', gracefulShutdown);
1014
1044
  }
1015
1045
  catch (error) {
1016
1046
  handleError(error);
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { getConfigPath } from './utils/flags.js';
5
5
  import { getActiveProfile } from './lib/request-context.js';
6
+ import { emitJsonError, isJsonMode } from './utils/output.js';
6
7
  function sanitizeOptionalString(v) {
7
8
  if (typeof v !== 'string')
8
9
  return undefined;
@@ -51,20 +52,36 @@ export function loadConfig() {
51
52
  const hint = profile
52
53
  ? `No credentials configured for profile "${profile}". Run: switchbot --profile ${profile} config set-token <token> <secret>`
53
54
  : 'No credentials configured. Run: switchbot config set-token <token> <secret>';
54
- console.error(`${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`);
55
+ const msg = `${hint}\nOr set SWITCHBOT_TOKEN and SWITCHBOT_SECRET environment variables.`;
56
+ if (isJsonMode()) {
57
+ emitJsonError({ code: 1, kind: 'runtime', message: hint });
58
+ }
59
+ else {
60
+ console.error(msg);
61
+ }
55
62
  process.exit(1);
56
63
  }
57
64
  try {
58
65
  const raw = fs.readFileSync(file, 'utf-8');
59
66
  const cfg = JSON.parse(raw);
60
67
  if (!cfg.token || !cfg.secret) {
61
- console.error('Invalid config format. Please re-run: switchbot config set-token');
68
+ if (isJsonMode()) {
69
+ emitJsonError({ code: 1, kind: 'runtime', message: 'Invalid config format. Please re-run: switchbot config set-token' });
70
+ }
71
+ else {
72
+ console.error('Invalid config format. Please re-run: switchbot config set-token');
73
+ }
62
74
  process.exit(1);
63
75
  }
64
76
  return cfg;
65
77
  }
66
78
  catch {
67
- console.error('Failed to read config file. Please re-run: switchbot config set-token');
79
+ if (isJsonMode()) {
80
+ emitJsonError({ code: 1, kind: 'runtime', message: 'Failed to read config file. Please re-run: switchbot config set-token' });
81
+ }
82
+ else {
83
+ console.error('Failed to read config file. Please re-run: switchbot config set-token');
84
+ }
68
85
  process.exit(1);
69
86
  }
70
87
  }
@@ -156,36 +173,63 @@ export function readProfileMeta(profile) {
156
173
  }
157
174
  }
158
175
  export function showConfig() {
176
+ const summary = getConfigSummary();
177
+ if (summary.source === 'env') {
178
+ console.log('Credential source: environment variables');
179
+ console.log(`token : ${summary.token ?? ''}`);
180
+ console.log(`secret: ${summary.secret ?? ''}`);
181
+ return;
182
+ }
183
+ if (summary.source === 'none') {
184
+ console.log('No credentials configured');
185
+ return;
186
+ }
187
+ if (summary.source === 'invalid') {
188
+ console.error('Failed to read config file');
189
+ return;
190
+ }
191
+ console.log(`Credential source: ${summary.path}`);
192
+ if (summary.label)
193
+ console.log(`label : ${summary.label}`);
194
+ if (summary.description)
195
+ console.log(`desc : ${summary.description}`);
196
+ console.log(`token : ${summary.token ?? ''}`);
197
+ console.log(`secret: ${summary.secret ?? ''}`);
198
+ if (summary.dailyCap)
199
+ console.log(`limits: dailyCap=${summary.dailyCap}`);
200
+ if (summary.defaultFlags?.length)
201
+ console.log(`defaults: ${summary.defaultFlags.join(' ')}`);
202
+ }
203
+ export function getConfigSummary() {
159
204
  const envToken = process.env.SWITCHBOT_TOKEN;
160
205
  const envSecret = process.env.SWITCHBOT_SECRET;
161
206
  if (envToken && envSecret) {
162
- console.log('Credential source: environment variables');
163
- console.log(`token : ${maskCredential(envToken)}`);
164
- console.log(`secret: ${maskSecret(envSecret)}`);
165
- return;
207
+ return {
208
+ source: 'env',
209
+ token: maskCredential(envToken),
210
+ secret: maskSecret(envSecret),
211
+ };
166
212
  }
167
213
  const file = configFilePath();
168
214
  if (!fs.existsSync(file)) {
169
- console.log('No credentials configured');
170
- return;
215
+ return { source: 'none' };
171
216
  }
172
217
  try {
173
218
  const raw = fs.readFileSync(file, 'utf-8');
174
219
  const cfg = JSON.parse(raw);
175
- console.log(`Credential source: ${file}`);
176
- if (cfg.label)
177
- console.log(`label : ${cfg.label}`);
178
- if (cfg.description)
179
- console.log(`desc : ${cfg.description}`);
180
- console.log(`token : ${maskCredential(cfg.token)}`);
181
- console.log(`secret: ${maskSecret(cfg.secret)}`);
182
- if (cfg.limits?.dailyCap)
183
- console.log(`limits: dailyCap=${cfg.limits.dailyCap}`);
184
- if (cfg.defaults?.flags?.length)
185
- console.log(`defaults: ${cfg.defaults.flags.join(' ')}`);
220
+ return {
221
+ source: 'file',
222
+ path: file,
223
+ label: cfg.label,
224
+ description: cfg.description,
225
+ token: maskCredential(cfg.token),
226
+ secret: maskSecret(cfg.secret),
227
+ dailyCap: cfg.limits?.dailyCap,
228
+ defaultFlags: cfg.defaults?.flags,
229
+ };
186
230
  }
187
231
  catch {
188
- console.error('Failed to read config file');
232
+ return { source: 'invalid', path: file };
189
233
  }
190
234
  }
191
235
  function maskCredential(token) {
@@ -219,7 +219,7 @@ export const DEVICE_CATALOG = [
219
219
  category: 'physical',
220
220
  description: 'Entry-level robot vacuum with start/stop/dock and four suction power levels.',
221
221
  role: 'cleaning',
222
- aliases: ['Robot Vacuum Cleaner S1 Plus', 'K10+'],
222
+ aliases: ['Robot Vacuum', 'Robot Vacuum Cleaner S1 Plus', 'K10+'],
223
223
  commands: [
224
224
  { command: 'start', parameter: '—', description: 'Start cleaning', idempotent: true },
225
225
  { command: 'stop', parameter: '—', description: 'Stop cleaning', idempotent: true },
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ 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
8
  import { registerConfigCommand } from './commands/config.js';
8
9
  import { registerDevicesCommand } from './commands/devices.js';
9
10
  import { registerScenesCommand } from './commands/scenes.js';
@@ -28,6 +29,11 @@ if (process.argv.includes('--no-color') || Boolean(process.env.NO_COLOR)) {
28
29
  chalk.level = 0;
29
30
  }
30
31
  const program = new Command();
32
+ if (isJsonMode()) {
33
+ // In --json mode, commander writes plain-text usage errors by default.
34
+ // Silence that channel and emit a single structured error in the catch block.
35
+ program.configureOutput({ writeErr: () => { } });
36
+ }
31
37
  // Top-level subcommand names. Used by stringArg to produce clearer errors when
32
38
  // a value is omitted and the next argv token turns out to be a subcommand name.
33
39
  const TOP_LEVEL_COMMANDS = [
@@ -130,10 +136,7 @@ Docs: https://github.com/OpenWonderLabs/SwitchBotAPI
130
136
  // per-command: subcommand errors won't bubble to the root override, so walk
131
137
  // every registered command and apply the same handler.
132
138
  const usageExitHandler = (err) => {
133
- if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
134
- process.exit(0);
135
- }
136
- process.exit(2);
139
+ throw err;
137
140
  };
138
141
  function applyExitOverride(cmd) {
139
142
  cmd.exitOverride(usageExitHandler);
@@ -158,6 +161,9 @@ catch (err) {
158
161
  if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
159
162
  process.exit(0);
160
163
  }
164
+ if (isJsonMode()) {
165
+ emitJsonError({ code: 2, kind: 'usage', message: err.message });
166
+ }
161
167
  process.exit(2);
162
168
  }
163
169
  throw err;
@@ -151,8 +151,12 @@ export function validateCommand(deviceId, cmd, parameter, commandType) {
151
151
  if (!match || Array.isArray(match))
152
152
  return { ok: true };
153
153
  const builtinCommands = match.commands.filter((c) => c.commandType !== 'customize');
154
- if (builtinCommands.length === 0)
155
- return { ok: true };
154
+ if (match.readOnly || builtinCommands.length === 0) {
155
+ return {
156
+ ok: false,
157
+ error: new CommandValidationError(`${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, 'read-only-device', `Use 'switchbot devices status ${deviceId}' to read this device instead.`),
158
+ };
159
+ }
156
160
  let spec = builtinCommands.find((c) => c.command === cmd);
157
161
  let caseNormalizedFrom;
158
162
  let normalizedCmd = cmd;
@@ -0,0 +1,36 @@
1
+ import { UsageError } from '../utils/output.js';
2
+ export const FIELD_ALIASES = {
3
+ deviceId: ['id'],
4
+ deviceName: ['name'],
5
+ deviceType: ['type'],
6
+ controlType: ['control'],
7
+ roomName: ['room'],
8
+ roomID: ['roomid'],
9
+ familyName: ['family'],
10
+ hubDeviceId: ['hub'],
11
+ enableCloudService: ['cloud'],
12
+ alias: ['alias'],
13
+ };
14
+ export function resolveField(input, allowedCanonical) {
15
+ const normalized = input.trim().toLowerCase();
16
+ if (!normalized) {
17
+ throw new UsageError('Field name cannot be empty.');
18
+ }
19
+ for (const canonical of allowedCanonical) {
20
+ if (canonical.toLowerCase() === normalized)
21
+ return canonical;
22
+ const aliases = FIELD_ALIASES[canonical] ?? [];
23
+ if (aliases.some((a) => a.toLowerCase() === normalized))
24
+ return canonical;
25
+ }
26
+ throw new UsageError(`Unknown field "${input}". Supported: ${listSupportedFieldInputs(allowedCanonical).join(', ')}`);
27
+ }
28
+ export function listSupportedFieldInputs(allowedCanonical) {
29
+ const out = new Set();
30
+ for (const canonical of allowedCanonical) {
31
+ out.add(canonical);
32
+ for (const alias of FIELD_ALIASES[canonical] ?? [])
33
+ out.add(alias);
34
+ }
35
+ return [...out];
36
+ }
@@ -20,7 +20,7 @@ export class FilterSyntaxError extends Error {
20
20
  * {type,name,category,room}; `devices batch` uses {type,family,room,category};
21
21
  * `events tail` uses {deviceId,type}.
22
22
  */
23
- export function parseFilterExpr(expr, allowedKeys) {
23
+ export function parseFilterExpr(expr, allowedKeys, options) {
24
24
  if (!expr)
25
25
  return [];
26
26
  const parts = expr.split(',').map((p) => p.trim()).filter((p) => p.length > 0);
@@ -72,10 +72,23 @@ export function parseFilterExpr(expr, allowedKeys) {
72
72
  if (!raw) {
73
73
  throw new FilterSyntaxError(`Empty value for filter clause "${part}"`);
74
74
  }
75
- if (!allowedKeys.includes(key)) {
76
- throw new FilterSyntaxError(`Unknown filter key "${key}" — supported: ${allowedKeys.join(', ')}`);
75
+ let resolvedKey = key;
76
+ if (options?.resolveKey) {
77
+ try {
78
+ resolvedKey = options.resolveKey(key);
79
+ }
80
+ catch (err) {
81
+ if (err instanceof Error) {
82
+ throw new FilterSyntaxError(err.message);
83
+ }
84
+ throw err;
85
+ }
86
+ }
87
+ if (!allowedKeys.includes(resolvedKey)) {
88
+ const printableKeys = options?.supportedKeys ?? allowedKeys;
89
+ throw new FilterSyntaxError(`Unknown filter key "${key}" – supported: ${printableKeys.join(', ')}`);
77
90
  }
78
- clauses.push({ key, op, raw, regex });
91
+ clauses.push({ key: resolvedKey, op, raw, regex });
79
92
  }
80
93
  return clauses;
81
94
  }
@@ -28,6 +28,26 @@ export function emitJsonError(errorPayload) {
28
28
  console.error(chalk.red(msg));
29
29
  }
30
30
  }
31
+ export function exitWithError(messageOrOpts) {
32
+ const opts = typeof messageOrOpts === 'string' ? { message: messageOrOpts } : messageOrOpts;
33
+ const { message, kind = 'usage', code = 2, hint, context, extra } = opts;
34
+ if (isJsonMode()) {
35
+ const payload = { code, kind, message };
36
+ if (hint)
37
+ payload.hint = hint;
38
+ if (context)
39
+ payload.context = context;
40
+ if (extra)
41
+ Object.assign(payload, extra);
42
+ emitJsonError(payload);
43
+ }
44
+ else {
45
+ console.error(message);
46
+ if (hint)
47
+ console.error(hint);
48
+ }
49
+ process.exit(code);
50
+ }
31
51
  function escapeMarkdownCell(s) {
32
52
  // Pipes break markdown table layout; backslash-escape them. Collapse
33
53
  // newlines into <br> so each row stays on one line.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchbot/openapi-cli",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
5
5
  "keywords": [
6
6
  "switchbot",