@switchbot/openapi-cli 2.6.3 → 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,7 +1,7 @@
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, exitWithError } from '../utils/output.js';
3
3
  import { resolveFormat, resolveFields, renderRows } from '../utils/format.js';
4
- import { findCatalogEntry, getEffectiveCatalog } from '../devices/catalog.js';
4
+ import { findCatalogEntry, getEffectiveCatalog, deriveSafetyTier, getCommandSafetyReason, } from '../devices/catalog.js';
5
5
  import { getCachedDevice, loadCache } from '../devices/cache.js';
6
6
  import { loadDeviceMeta } from '../devices/device-meta.js';
7
7
  import { resolveDeviceId, ALL_STRATEGIES } from '../utils/name-resolver.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, resolveFieldList, 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, familyName/family, hubDeviceId/hub, roomID/roomid, enableCloudService/cloud, alias.', stringArg('--filter'))
84
92
  .action(async (options) => {
85
93
  try {
86
94
  const body = await fetchDeviceList();
@@ -90,11 +98,34 @@ 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
+ 'family', 'hub', 'roomID', 'cloud', 'alias'];
103
+ const LIST_FILTER_CANONICAL = ['deviceId', 'deviceName', 'deviceType', 'controlType',
104
+ 'roomName', 'category', 'familyName', 'hubDeviceId', 'roomID',
105
+ 'enableCloudService', 'alias'];
106
+ const LIST_FILTER_TO_RUNTIME = {
107
+ deviceId: 'deviceId',
108
+ deviceName: 'name',
109
+ deviceType: 'type',
110
+ controlType: 'controlType',
111
+ roomName: 'room',
112
+ category: 'category',
113
+ familyName: 'family',
114
+ hubDeviceId: 'hub',
115
+ roomID: 'roomID',
116
+ enableCloudService: 'cloud',
117
+ alias: 'alias',
118
+ };
94
119
  let listClauses = null;
95
120
  if (options.filter) {
96
121
  try {
97
- listClauses = parseFilterExpr(options.filter, LIST_KEYS);
122
+ listClauses = parseFilterExpr(options.filter, LIST_KEYS, {
123
+ resolveKey: (input) => {
124
+ const canonical = resolveField(input, LIST_FILTER_CANONICAL);
125
+ return LIST_FILTER_TO_RUNTIME[canonical];
126
+ },
127
+ supportedKeys: listSupportedFieldInputs(LIST_FILTER_CANONICAL),
128
+ });
98
129
  }
99
130
  catch (err) {
100
131
  if (err instanceof FilterSyntaxError)
@@ -114,10 +145,10 @@ Examples:
114
145
  };
115
146
  if (fmt === 'json' && process.argv.includes('--json')) {
116
147
  if (listClauses) {
117
- const filteredDeviceList = deviceList.filter((d) => matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }));
148
+ const filteredDeviceList = deviceList.filter((d) => matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }));
118
149
  const filteredIrList = infraredRemoteList.filter((d) => {
119
150
  const inherited = hubLocation.get(d.hubDeviceId);
120
- return matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' });
151
+ return matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' });
121
152
  });
122
153
  printJson({ ok: true, deviceList: filteredDeviceList, infraredRemoteList: filteredIrList });
123
154
  }
@@ -134,7 +165,7 @@ Examples:
134
165
  for (const d of deviceList) {
135
166
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
136
167
  continue;
137
- if (!matchesFilter({ type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '' }))
168
+ if (!matchesFilter({ deviceId: d.deviceId, type: d.deviceType || '', name: d.deviceName, category: 'physical', room: d.roomName || '', controlType: d.controlType || '', family: d.familyName || '', hub: d.hubDeviceId || '', roomID: d.roomID || '', cloud: String(d.enableCloudService), alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
138
169
  continue;
139
170
  rows.push([
140
171
  d.deviceId,
@@ -154,7 +185,7 @@ Examples:
154
185
  if (!options.showHidden && deviceMeta.devices[d.deviceId]?.hidden)
155
186
  continue;
156
187
  const inherited = hubLocation.get(d.hubDeviceId);
157
- if (!matchesFilter({ type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '' }))
188
+ if (!matchesFilter({ deviceId: d.deviceId, type: d.remoteType, name: d.deviceName, category: 'ir', room: inherited?.room || '', controlType: d.controlType || '', family: inherited?.family || '', hub: d.hubDeviceId || '', roomID: inherited?.roomID || '', cloud: '', alias: deviceMeta.devices[d.deviceId]?.alias || '' }))
158
189
  continue;
159
190
  rows.push([
160
191
  d.deviceId,
@@ -177,9 +208,19 @@ Examples:
177
208
  const defaultFields = options.wide ? undefined : narrowHeaders;
178
209
  // Accept API field names and short aliases alongside canonical column names
179
210
  const DEVICE_LIST_ALIASES = {
180
- id: 'deviceId', name: 'deviceName', deviceType: 'type', type: 'type',
181
- roomName: 'room', familyName: 'family',
182
- hubDeviceId: 'hub', enableCloudService: 'cloud',
211
+ id: 'deviceId',
212
+ name: 'deviceName',
213
+ deviceType: 'type',
214
+ type: 'type',
215
+ roomName: 'room',
216
+ familyName: 'family',
217
+ hubDeviceId: 'hub',
218
+ enableCloudService: 'cloud',
219
+ controlType: 'controlType',
220
+ deviceName: 'deviceName',
221
+ deviceId: 'deviceId',
222
+ category: 'category',
223
+ alias: 'alias',
183
224
  };
184
225
  renderRows(wideHeaders, rows, fmt, userFields ?? defaultFields, DEVICE_LIST_ALIASES);
185
226
  if (fmt === 'table') {
@@ -247,7 +288,7 @@ Examples:
247
288
  }
248
289
  }
249
290
  else {
250
- const fields = resolveFields();
291
+ const rawFields = resolveFields();
251
292
  for (const entry of batch) {
252
293
  const { deviceId, ok, error, _fetchedAt: ts, ...status } = entry;
253
294
  console.log(`\n─── ${String(deviceId)} ───`);
@@ -255,9 +296,13 @@ Examples:
255
296
  console.error(` error: ${String(error)}`);
256
297
  }
257
298
  else {
299
+ const statusMap = status;
300
+ const fields = rawFields
301
+ ? resolveFieldList(rawFields, Object.keys(statusMap))
302
+ : undefined;
258
303
  const displayStatus = fields
259
- ? Object.fromEntries(fields.map((f) => [f, status[f] ?? null]))
260
- : status;
304
+ ? Object.fromEntries(fields.map((f) => [f, statusMap[f] ?? null]))
305
+ : statusMap;
261
306
  printKeyValue(displayStatus);
262
307
  console.error(` fetched at ${String(ts)}`);
263
308
  }
@@ -282,7 +327,10 @@ Examples:
282
327
  const statusWithTs = { ...body, _fetchedAt: fetchedAt };
283
328
  const allHeaders = Object.keys(statusWithTs);
284
329
  const allRows = [Object.values(statusWithTs)];
285
- const fields = resolveFields();
330
+ const rawFields = resolveFields();
331
+ const fields = rawFields
332
+ ? resolveFieldList(rawFields, allHeaders)
333
+ : undefined;
286
334
  renderRows(allHeaders, allRows, fmt, fields);
287
335
  return;
288
336
  }
@@ -420,26 +468,22 @@ Examples:
420
468
  const validation = validateCommand(deviceId, cmd, parameter, options.type);
421
469
  if (!validation.ok) {
422
470
  const err = validation.error;
423
- if (isJsonMode()) {
424
- const obj = { code: 2, kind: 'usage', message: err.message };
425
- if (err.hint)
426
- obj.hint = err.hint;
427
- obj.context = { validationKind: err.kind };
428
- emitJsonError(obj);
429
- }
430
- else {
431
- console.error(`Error: ${err.message}`);
432
- if (err.hint)
433
- console.error(err.hint);
434
- if (err.kind === 'unknown-command') {
435
- const cached = getCachedDevice(deviceId);
436
- if (cached) {
437
- console.error(`Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.`);
438
- console.error(`(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`);
439
- }
471
+ let hint = err.hint;
472
+ if (err.kind === 'unknown-command') {
473
+ const cached = getCachedDevice(deviceId);
474
+ if (cached) {
475
+ const extra = `Run 'switchbot devices commands ${JSON.stringify(cached.type)}' for parameter formats and descriptions.\n` +
476
+ `(If the catalog is out of date, run 'switchbot devices list' to refresh the local cache, or pass --type customize for custom IR buttons.)`;
477
+ hint = hint ? `${hint}\n${extra}` : extra;
440
478
  }
441
479
  }
442
- process.exit(2);
480
+ exitWithError({
481
+ code: 2,
482
+ kind: 'usage',
483
+ message: err.message,
484
+ hint,
485
+ context: { validationKind: err.kind },
486
+ });
443
487
  }
444
488
  // Case-only mismatch: emit a warning and continue with the canonical name.
445
489
  if (validation.caseNormalizedFrom && validation.normalized) {
@@ -454,18 +498,10 @@ Examples:
454
498
  if (cachedForParam && options.type === 'command' && !options.skipParamValidation) {
455
499
  const paramCheck = validateParameter(cachedForParam.type, cmd, parameter);
456
500
  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);
501
+ exitWithError({
502
+ message: `Error: ${paramCheck.error}`,
503
+ context: { command: cmd, deviceType: cachedForParam.type, deviceId, humanHint: paramCheck.error },
504
+ });
469
505
  }
470
506
  if (paramCheck.normalized !== undefined)
471
507
  parameter = paramCheck.normalized;
@@ -476,24 +512,14 @@ Examples:
476
512
  isDestructiveCommand(cachedForGuard?.type, cmd, options.type)) {
477
513
  const typeLabel = cachedForGuard?.type ?? 'unknown';
478
514
  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);
515
+ exitWithError({
516
+ kind: 'guard',
517
+ message: `Refusing to run destructive command "${cmd}" on ${typeLabel} without --yes.`,
518
+ hint: reason
519
+ ? `Re-run with --yes to confirm. Reason: ${reason}`
520
+ : 'Re-run with --yes to confirm, or --dry-run to preview without sending.',
521
+ context: { command: cmd, deviceType: typeLabel, deviceId, ...(reason ? { safetyReason: reason, destructiveReason: reason } : {}) },
522
+ });
497
523
  }
498
524
  // Warn when --yes is given but the command is not destructive (no-op flag)
499
525
  if (options.yes && !isDestructiveCommand(cachedForGuard?.type, cmd, options.type) && !isDryRun()) {
@@ -630,7 +656,7 @@ Examples:
630
656
  const joinedMatch = findCatalogEntry(joined);
631
657
  if (joinedMatch && !Array.isArray(joinedMatch)) {
632
658
  if (isJsonMode()) {
633
- printJson(joinedMatch);
659
+ printJson(normalizeCatalogForJson(joinedMatch));
634
660
  }
635
661
  else {
636
662
  renderCatalogEntry(joinedMatch);
@@ -649,7 +675,7 @@ Examples:
649
675
  }
650
676
  if (individualMatches.length === typeParts.length) {
651
677
  if (isJsonMode()) {
652
- printJson(individualMatches);
678
+ printJson(individualMatches.map(normalizeCatalogForJson));
653
679
  }
654
680
  else {
655
681
  individualMatches.forEach((entry, i) => {
@@ -702,7 +728,8 @@ JSON output shape (--json):
702
728
  liveStatus: <status payload when --live was passed>
703
729
  },
704
730
  source: "catalog" | "live" | "catalog+live" | "none",
705
- suggestedActions: [{command, parameter?, description}]
731
+ suggestedActions: [{command, parameter?, description}],
732
+ expandHint?: {command, flags, example} // present when the type supports 'devices expand'
706
733
  }
707
734
 
708
735
  Examples:
@@ -722,6 +749,7 @@ Examples:
722
749
  const result = await describeDevice(deviceId, options);
723
750
  const { device, isPhysical, typeName, controlType, catalog, capabilities, source, suggestedActions: picks } = result;
724
751
  if (isJsonMode()) {
752
+ const expandHint = catalog ? EXPAND_HINTS[catalog.type] : undefined;
725
753
  printJson({
726
754
  device,
727
755
  controlType,
@@ -729,6 +757,7 @@ Examples:
729
757
  capabilities,
730
758
  source,
731
759
  suggestedActions: picks,
760
+ ...(expandHint ? { expandHint: { command: expandHint.command, flags: expandHint.flags, example: `switchbot devices expand ${deviceId} ${expandHint.command} ${expandHint.flags}` } } : {}),
732
761
  });
733
762
  return;
734
763
  }
@@ -785,20 +814,12 @@ Examples:
785
814
  catch (error) {
786
815
  if (error instanceof DeviceNotFoundError) {
787
816
  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);
817
+ exitWithError({
818
+ code: 1,
819
+ kind: 'runtime',
820
+ message,
821
+ extra: { errorClass: 'runtime', transient: false },
822
+ });
802
823
  }
803
824
  handleError(error);
804
825
  }
@@ -814,6 +835,21 @@ Examples:
814
835
  // switchbot devices meta set/get/list/clear
815
836
  registerDevicesMetaCommand(devices);
816
837
  }
838
+ function normalizeCatalogForJson(entry) {
839
+ return {
840
+ ...entry,
841
+ commands: entry.commands.map((c) => {
842
+ const tier = deriveSafetyTier(c, entry);
843
+ const reason = getCommandSafetyReason(c);
844
+ return {
845
+ ...c,
846
+ safetyTier: tier,
847
+ destructive: tier === 'destructive',
848
+ ...(reason ? { safetyReason: reason } : {}),
849
+ };
850
+ }),
851
+ };
852
+ }
817
853
  function renderCatalogEntry(entry) {
818
854
  console.log(`Type: ${entry.type}`);
819
855
  console.log(`Category: ${entry.category === 'ir' ? 'IR remote' : 'Physical device'}`);
@@ -831,10 +867,11 @@ function renderCatalogEntry(entry) {
831
867
  console.log('\nCommands:');
832
868
  const hasExamples = entry.commands.some((c) => c.exampleParams && c.exampleParams.length > 0);
833
869
  const rows = entry.commands.map((c) => {
870
+ const tier = deriveSafetyTier(c, entry);
834
871
  const flags = [];
835
872
  if (c.commandType === 'customize')
836
873
  flags.push('customize');
837
- if (c.destructive)
874
+ if (tier === 'destructive')
838
875
  flags.push('!destructive');
839
876
  const label = flags.length > 0 ? `${c.command} [${flags.join(', ')}]` : c.command;
840
877
  const base = [label, c.parameter, c.description];
@@ -844,7 +881,7 @@ function renderCatalogEntry(entry) {
844
881
  ? ['command', 'parameter', 'description', 'example']
845
882
  : ['command', 'parameter', 'description'];
846
883
  printTable(tableHeaders, rows);
847
- const hasDestructive = entry.commands.some((c) => c.destructive);
884
+ const hasDestructive = entry.commands.some((c) => deriveSafetyTier(c, entry) === 'destructive');
848
885
  if (hasDestructive) {
849
886
  console.log('\n[!destructive] commands have hard-to-reverse real-world effects — confirm before issuing.');
850
887
  }
@@ -853,4 +890,8 @@ function renderCatalogEntry(entry) {
853
890
  console.log('\nStatus fields (from "devices status"):');
854
891
  console.log(' ' + entry.statusFields.join(', '));
855
892
  }
893
+ const expandHint = EXPAND_HINTS[entry.type];
894
+ if (expandHint) {
895
+ console.log(`\nTip: Use 'devices expand <id> ${expandHint.command} ${expandHint.flags}' for semantic flags instead of raw parameters.`);
896
+ }
856
897
  }