@switchbot/openapi-cli 2.6.3 → 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.
@@ -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,52 +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
- // Bug #55: validateCommand is lenient by design (passes unknown device
309
- // types, ambiguous catalog matches). For dry-run we need stricter
310
- // checking — query the catalog directly and reject unknown commands
311
- // when the catalog has a definitive match.
312
- if (effectiveType !== 'customize') {
313
- const catalogMatch = findCatalogEntry(cached.type);
314
- if (catalogMatch && !Array.isArray(catalogMatch)) {
315
- const builtinCmds = catalogMatch.commands.filter((c) => c.commandType !== 'customize');
316
- if (builtinCmds.length > 0) {
317
- const exactMatch = builtinCmds.find((c) => c.command === command);
318
- const caseMatch = !exactMatch
319
- ? builtinCmds.find((c) => c.command.toLowerCase() === command.toLowerCase())
320
- : null;
321
- if (!exactMatch && !caseMatch) {
322
- const supported = [...new Set(builtinCmds.map((c) => c.command))].join(', ');
323
- return mcpError('usage', 2, `"${command}" is not a supported command for ${cached.name} (${cached.type}).`, {
324
- hint: `Supported commands: ${supported}`,
325
- context: { validationKind: 'unknown-command', deviceType: cached.type, command },
326
- });
327
- }
328
- }
329
- else if (catalogMatch.readOnly) {
330
- return mcpError('usage', 2, `${cached.name} (${cached.type}) is a read-only sensor — it has no control commands.`, {
331
- hint: "Use 'get_device_status' to read this device instead.",
332
- context: { validationKind: 'read-only-device', deviceType: cached.type, command },
333
- });
334
- }
335
- }
336
- }
337
323
  const wouldSend = {
338
324
  deviceId,
339
- command,
325
+ command: effectiveCommand,
340
326
  parameter: effectiveParameter ?? 'default',
341
327
  commandType: effectiveType,
342
328
  };
@@ -361,19 +347,19 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
361
347
  }
362
348
  typeName = physical ? physical.deviceType : ir.remoteType;
363
349
  }
364
- if (isDestructiveCommand(typeName, command, effectiveType) && !confirm) {
365
- const reason = getDestructiveReason(typeName, command, effectiveType);
350
+ if (isDestructiveCommand(typeName, effectiveCommand, effectiveType) && !confirm) {
351
+ const reason = getDestructiveReason(typeName, effectiveCommand, effectiveType);
366
352
  const entry = typeName ? findCatalogEntry(typeName) : null;
367
353
  const spec = entry && !Array.isArray(entry)
368
- ? entry.commands.find((c) => c.command === command)
354
+ ? entry.commands.find((c) => c.command === effectiveCommand)
369
355
  : undefined;
370
356
  const hint = reason
371
357
  ? `Re-issue with confirm:true after confirming with the user. Reason: ${reason}`
372
358
  : 'Re-issue the call with confirm:true to proceed.';
373
- 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.`, {
374
360
  hint,
375
361
  context: {
376
- command,
362
+ command: effectiveCommand,
377
363
  deviceType: typeName,
378
364
  description: spec?.description ?? null,
379
365
  ...(reason ? { destructiveReason: reason } : {}),
@@ -383,18 +369,24 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
383
369
  // validateCommand covers command existence + required/unexpected-parameter.
384
370
  // stringifiedParam was computed once at the top of the handler so dry-run
385
371
  // and live paths share the same shape.
386
- const validation = validateCommand(deviceId, command, stringifiedParam, effectiveType);
372
+ const validation = validateCommand(deviceId, effectiveCommand, stringifiedParam, effectiveType);
387
373
  if (!validation.ok) {
388
- 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;
389
381
  }
390
382
  // R-2: run B-1 client-side parameter validator (range/format checks).
391
383
  // Customize commands (user-defined IR buttons) opt out — the catalog
392
384
  // cannot know their expected shape.
393
385
  if (effectiveType !== 'customize') {
394
- const pv = validateParameter(typeName, command, stringifiedParam);
386
+ const pv = validateParameter(typeName, effectiveCommand, stringifiedParam);
395
387
  if (!pv.ok) {
396
388
  return mcpError('usage', 2, pv.error, {
397
- context: { deviceType: typeName, command, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
389
+ context: { deviceType: typeName, command: effectiveCommand, parameter: stringifiedParam, validationKind: 'param-out-of-range' },
398
390
  });
399
391
  }
400
392
  if (pv.normalized !== undefined) {
@@ -403,7 +395,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
403
395
  }
404
396
  let result;
405
397
  try {
406
- result = await executeCommand(deviceId, command, effectiveParameter, effectiveType, undefined, {
398
+ result = await executeCommand(deviceId, effectiveCommand, effectiveParameter, effectiveType, undefined, {
407
399
  idempotencyKey,
408
400
  });
409
401
  }
@@ -420,7 +412,7 @@ API docs: https://github.com/OpenWonderLabs/SwitchBotAPI`,
420
412
  return apiErrorToMcpError(err);
421
413
  }
422
414
  const isIr = getCachedDevice(deviceId)?.category === 'ir';
423
- const structured = { ok: true, command, deviceId, result };
415
+ const structured = { ok: true, command: effectiveCommand, deviceId, result };
424
416
  if (isIr) {
425
417
  structured.verification = {
426
418
  verifiable: false,
@@ -786,19 +778,19 @@ Inspect locally:
786
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'))
787
779
  .option('--cors-origin <url>', 'Allowed CORS origin(s) for HTTP (repeatable)', stringArg('--cors-origin'))
788
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
+ `)
789
788
  .action(async (options) => {
790
789
  try {
791
790
  if (options.port) {
792
791
  const port = Number(options.port);
793
792
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
794
- const msg = `Invalid --port "${options.port}". Must be 1-65535.`;
795
- if (isJsonMode()) {
796
- emitJsonError({ code: 2, kind: 'usage', message: msg });
797
- }
798
- else {
799
- console.error(msg);
800
- }
801
- process.exit(2);
793
+ exitWithError(`Invalid --port "${options.port}". Must be 1-65535.`);
802
794
  }
803
795
  const bind = options.bind ?? '127.0.0.1';
804
796
  const authToken = options.authToken ?? process.env.SWITCHBOT_MCP_TOKEN;
@@ -807,14 +799,7 @@ Inspect locally:
807
799
  // Guard: refuse to bind non-localhost without auth
808
800
  const isLocalhost = bind === '127.0.0.1' || bind === 'localhost' || bind === '::1';
809
801
  if (!isLocalhost && !authToken) {
810
- const msg = 'Refusing to listen on 0.0.0.0 without --auth-token. Pass --auth-token <token> or bind to localhost (default).';
811
- if (isJsonMode()) {
812
- emitJsonError({ code: 2, kind: 'usage', message: msg });
813
- }
814
- else {
815
- console.error(msg);
816
- }
817
- 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).');
818
803
  }
819
804
  const { createServer } = await import('node:http');
820
805
  const rateLimitMap = new Map();
@@ -1033,6 +1018,29 @@ process_uptime_seconds ${Math.floor(process.uptime())}
1033
1018
  const server = createSwitchBotMcpServer({ eventManager });
1034
1019
  const transport = new StdioServerTransport();
1035
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);
1036
1044
  }
1037
1045
  catch (error) {
1038
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.3",
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",